fix: critical security hardening (Phase A permissions audit)
- Remove role field from UserCreate schema, hardcode 'engineer' at registration
- Escape all user content in HTML export with html.escape() (XSS fix)
- Add field_validator to reject default SECRET_KEY when DEBUG=False
- Add CHECK constraint on users.role ('engineer'|'viewer') + migration 011
- Fix test_admin fixture to properly grant is_super_admin via ORM
- Fix circular FK (users↔invite_codes) in test DB setup with DROP SCHEMA CASCADE
- Add 5 new security tests (role validation + XSS prevention)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ Provides test database setup, client fixtures, and authentication helpers.
|
||||
import asyncio
|
||||
from typing import AsyncGenerator, Generator
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
@@ -35,9 +36,10 @@ async def test_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
|
||||
This fixture:
|
||||
1. Creates a test database engine
|
||||
2. Creates all tables
|
||||
3. Yields a session for the test
|
||||
4. Drops all tables after the test
|
||||
2. Drops all existing tables (CASCADE to handle circular FKs)
|
||||
3. Creates all tables
|
||||
4. Yields a session for the test
|
||||
5. Drops all tables after the test
|
||||
"""
|
||||
# Create async engine for tests (with NullPool to avoid connection reuse issues)
|
||||
engine = create_async_engine(
|
||||
@@ -46,8 +48,11 @@ async def test_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
echo=False
|
||||
)
|
||||
|
||||
# Create all tables
|
||||
# Drop and recreate all tables (use raw SQL CASCADE to handle circular FKs
|
||||
# between users <-> invite_codes)
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(sa.text("DROP SCHEMA public CASCADE"))
|
||||
await conn.execute(sa.text("CREATE SCHEMA public"))
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
# Create async session maker
|
||||
@@ -61,9 +66,10 @@ async def test_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with async_session_maker() as session:
|
||||
yield session
|
||||
|
||||
# Drop all tables after test
|
||||
# Drop all tables after test (CASCADE for circular FKs)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await conn.execute(sa.text("DROP SCHEMA public CASCADE"))
|
||||
await conn.execute(sa.text("CREATE SCHEMA public"))
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
@@ -99,8 +105,7 @@ async def test_user(client):
|
||||
user_data = {
|
||||
"email": "test@example.com",
|
||||
"password": "TestPassword123!",
|
||||
"name": "Test User",
|
||||
"role": "engineer"
|
||||
"name": "Test User"
|
||||
}
|
||||
|
||||
response = await client.post("/api/v1/auth/register", json=user_data)
|
||||
@@ -181,23 +186,32 @@ async def test_tree(client, auth_headers):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_admin(client):
|
||||
async def test_admin(client, test_db):
|
||||
"""
|
||||
Create a test admin user and return their credentials.
|
||||
Create a test super-admin user.
|
||||
|
||||
Returns:
|
||||
dict with email, password, and user_data
|
||||
Registers as engineer (the only role available at registration),
|
||||
then promotes to super_admin directly via the DB session.
|
||||
"""
|
||||
from uuid import UUID as PyUUID
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
|
||||
admin_data = {
|
||||
"email": "admin@example.com",
|
||||
"password": "AdminPassword123!",
|
||||
"name": "Test Admin",
|
||||
"role": "admin"
|
||||
"name": "Test Admin"
|
||||
}
|
||||
|
||||
response = await client.post("/api/v1/auth/register", json=admin_data)
|
||||
assert response.status_code == 200 or response.status_code == 201
|
||||
|
||||
user_id = PyUUID(response.json()["id"])
|
||||
result = await test_db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one()
|
||||
user.is_super_admin = True
|
||||
await test_db.commit()
|
||||
|
||||
return {
|
||||
"email": admin_data["email"],
|
||||
"password": admin_data["password"],
|
||||
|
||||
@@ -13,8 +13,7 @@ class TestAuthentication:
|
||||
user_data = {
|
||||
"email": "newuser@example.com",
|
||||
"password": "SecurePass123!",
|
||||
"name": "New User",
|
||||
"role": "engineer"
|
||||
"name": "New User"
|
||||
}
|
||||
|
||||
response = await client.post("/api/v1/auth/register", json=user_data)
|
||||
@@ -23,7 +22,7 @@ class TestAuthentication:
|
||||
data = response.json()
|
||||
assert data["email"] == user_data["email"]
|
||||
assert data["name"] == user_data["name"]
|
||||
assert data["role"] == user_data["role"]
|
||||
assert data["role"] == "engineer"
|
||||
assert "id" in data
|
||||
assert "password" not in data # Password should not be returned
|
||||
|
||||
@@ -35,8 +34,7 @@ class TestAuthentication:
|
||||
user_data = {
|
||||
"email": test_user["email"], # Use existing email
|
||||
"password": "AnotherPass123!",
|
||||
"name": "Another User",
|
||||
"role": "engineer"
|
||||
"name": "Another User"
|
||||
}
|
||||
|
||||
response = await client.post("/api/v1/auth/register", json=user_data)
|
||||
@@ -93,3 +91,33 @@ class TestAuthentication:
|
||||
response = await client.get("/api/v1/auth/me")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_with_role_field_ignored(self, client: AsyncClient):
|
||||
"""Test that sending a role field at registration is ignored — always engineer."""
|
||||
user_data = {
|
||||
"email": "hacker@example.com",
|
||||
"password": "HackerPass123!",
|
||||
"name": "Hacker",
|
||||
"role": "admin"
|
||||
}
|
||||
|
||||
response = await client.post("/api/v1/auth/register", json=user_data)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["role"] == "engineer"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_default_role_is_engineer(self, client: AsyncClient):
|
||||
"""Test that omitting role defaults to engineer."""
|
||||
user_data = {
|
||||
"email": "default@example.com",
|
||||
"password": "DefaultPass123!",
|
||||
"name": "Default User"
|
||||
}
|
||||
|
||||
response = await client.post("/api/v1/auth/register", json=user_data)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["role"] == "engineer"
|
||||
|
||||
@@ -603,3 +603,84 @@ class TestSessions:
|
||||
assert response.status_code == 200
|
||||
content = response.text
|
||||
assert "Evidence / Reference" not in content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_html_export_escapes_script_tags(
|
||||
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||
):
|
||||
"""Test that HTML export escapes script tags in user content (XSS prevention)."""
|
||||
session_data = {
|
||||
"tree_id": test_tree["id"],
|
||||
"ticket_number": '<script>alert("xss")</script>',
|
||||
"client_name": '<img onerror="alert(1)" src=x>'
|
||||
}
|
||||
create_response = await client.post(
|
||||
"/api/v1/sessions", json=session_data, headers=auth_headers
|
||||
)
|
||||
session_id = create_response.json()["id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/sessions/{session_id}/export",
|
||||
json={"format": "html", "include_tree_info": True},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.text
|
||||
assert '<script>' not in content
|
||||
assert '<script>' in content
|
||||
assert '<img' in content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_html_export_escapes_special_chars(
|
||||
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||
):
|
||||
"""Test that HTML export properly escapes special characters."""
|
||||
session_data = {
|
||||
"tree_id": test_tree["id"],
|
||||
"ticket_number": 'TICK-001 <b>"bold"</b> & \'quoted\''
|
||||
}
|
||||
create_response = await client.post(
|
||||
"/api/v1/sessions", json=session_data, headers=auth_headers
|
||||
)
|
||||
session_id = create_response.json()["id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/sessions/{session_id}/export",
|
||||
json={"format": "html", "include_tree_info": True},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.text
|
||||
assert '&' in content
|
||||
assert '<b>' in content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_html_export_escapes_scratchpad(
|
||||
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||
):
|
||||
"""Test that HTML export escapes scratchpad content."""
|
||||
create_response = await client.post(
|
||||
"/api/v1/sessions",
|
||||
json={"tree_id": test_tree["id"]},
|
||||
headers=auth_headers
|
||||
)
|
||||
session_id = create_response.json()["id"]
|
||||
|
||||
await client.patch(
|
||||
f"/api/v1/sessions/{session_id}/scratchpad",
|
||||
json={"scratchpad": '<script>document.cookie</script>'},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/sessions/{session_id}/export",
|
||||
json={"format": "html"},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.text
|
||||
assert '<script>' not in content
|
||||
assert '<script>' in content
|
||||
|
||||
Reference in New Issue
Block a user