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:
chihlasm
2026-02-05 22:04:37 -05:00
parent fd8fab97bd
commit 3e0fb92012
10 changed files with 236 additions and 48 deletions

View File

@@ -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 '&lt;script&gt;' in content
assert '&lt;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 '&amp;' in content
assert '&lt;b&gt;' 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 '&lt;script&gt;' in content