From 3e0fb9201231f92587893f4ceff2cf3421a7577c Mon Sep 17 00:00:00 2001 From: chihlasm Date: Thu, 5 Feb 2026 22:04:37 -0500 Subject: [PATCH] fix: critical security hardening (Phase A permissions audit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CLAUDE.md | 14 +++- .../versions/011_add_role_check_constraint.py | 34 ++++++++ backend/app/api/endpoints/auth.py | 2 +- backend/app/api/endpoints/sessions.py | 47 +++++------ backend/app/core/config.py | 17 +++- backend/app/models/user.py | 8 +- backend/app/schemas/user.py | 1 - backend/tests/conftest.py | 42 ++++++---- backend/tests/test_auth.py | 38 +++++++-- backend/tests/test_sessions.py | 81 +++++++++++++++++++ 10 files changed, 236 insertions(+), 48 deletions(-) create mode 100644 backend/alembic/versions/011_add_role_check_constraint.py diff --git a/CLAUDE.md b/CLAUDE.md index a65e61d6..06979b9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,6 +105,12 @@ When adding new frontend pages or components, use "ResolutionFlow" for any user- - Frontend hook: `frontend/src/hooks/usePermissions.ts` - Viewers CAN: browse trees, start sessions, rate steps - Viewers CANNOT: create/edit trees, steps, tags, categories +- **Security Hardening (Phase A):** + - Registration role field removed from `UserCreate` — hardcoded to `engineer` + - HTML export XSS fix — all user content escaped via `html.escape()` + - Secret key validator — rejects default key when `DEBUG=False` + - Role CHECK constraint on `users` table (migration 011) + - `test_admin` fixture fixed to properly grant `is_super_admin=True` - **Session Scratchpad (Floating Overlay):** - Fixed-position overlay panel (420px wide, 55vh tall) on right edge - Floating button when collapsed, slide-in panel when expanded @@ -329,7 +335,7 @@ docker exec -it patherly_postgres psql -U postgres -c "CREATE DATABASE patherly_ pip install -r requirements-dev.txt # Run tests -pytest +pytest --override-ini="addopts=" ``` ### Frontend Operations @@ -513,6 +519,10 @@ When using `allow_origin_regex` for wildcard patterns, also include `allow_origi - Frontend: use `usePermissions()` hook for all role/permission checks - `TreeListItem` includes `team_id` for frontend permission checks (`author_id` and `team_id` are nullable) +### SQLAlchemy Test Fixtures: Circular FK Handling + +The `users ↔ invite_codes` tables have circular foreign keys. `Base.metadata.drop_all()` fails with `CircularDependencyError`. The test fixture uses `DROP SCHEMA public CASCADE` instead (split into two `sa.text()` calls — asyncpg rejects multi-statement strings). + ### Alembic Migrations: Test Data State Before Writing WHERE Clauses Migration 010 had `WHERE role = 'admin'` but the only user already had `role = 'engineer'` (changed by earlier work), so the UPDATE matched zero rows. Always verify actual data values before writing conditional migrations, or use broader conditions. @@ -753,7 +763,7 @@ Position overlay at `right-2` (not `right-0`) so it sits inside the page scrollb - Commit message format: `type: description` - Types: `feat`, `fix`, `refactor`, `docs`, `test`, `chore` -- Always include `Co-Authored-By: Claude Opus 4.5 ` +- Always include `Co-Authored-By: Claude Opus 4.6 ` - Always create a feature branch BEFORE committing new work (not retroactively after committing to main) - PR workflow: `git checkout -b feat/feature-name` → commit → `git push -u origin feat/feature-name` → `gh pr create` diff --git a/backend/alembic/versions/011_add_role_check_constraint.py b/backend/alembic/versions/011_add_role_check_constraint.py new file mode 100644 index 00000000..ebc78b6b --- /dev/null +++ b/backend/alembic/versions/011_add_role_check_constraint.py @@ -0,0 +1,34 @@ +"""add role check constraint to users + +Revision ID: 011 +Revises: 010 +Create Date: 2026-02-05 + +""" +from typing import Sequence, Union + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = '011' +down_revision: Union[str, None] = '010' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Normalize any remaining non-standard role values to 'engineer' + # (migration 010 already converted 'admin' -> 'engineer', this is defense-in-depth) + op.execute( + "UPDATE users SET role = 'engineer' WHERE role NOT IN ('engineer', 'viewer')" + ) + op.create_check_constraint( + 'ck_users_role_enum', + 'users', + "role IN ('engineer', 'viewer')" + ) + + +def downgrade() -> None: + op.drop_constraint('ck_users_role_enum', 'users', type_='check') diff --git a/backend/app/api/endpoints/auth.py b/backend/app/api/endpoints/auth.py index 34bd7e75..49589b4c 100644 --- a/backend/app/api/endpoints/auth.py +++ b/backend/app/api/endpoints/auth.py @@ -75,7 +75,7 @@ async def register( email=user_data.email, password_hash=get_password_hash(user_data.password), name=user_data.name, - role=user_data.role, + role="engineer", invite_code_id=invite_code_record.id if invite_code_record else None ) db.add(new_user) diff --git a/backend/app/api/endpoints/sessions.py b/backend/app/api/endpoints/sessions.py index 922563b3..f7ec7ab0 100644 --- a/backend/app/api/endpoints/sessions.py +++ b/backend/app/api/endpoints/sessions.py @@ -1,3 +1,4 @@ +import html from datetime import datetime, timezone from typing import Annotated, Optional from uuid import UUID @@ -348,9 +349,9 @@ def _generate_text_export(session: Session, options: SessionExport) -> str: def _generate_html_export(session: Session, options: SessionExport) -> str: """Generate HTML export.""" - tree_name = session.tree_snapshot.get("name", "Troubleshooting Session") + tree_name = html.escape(session.tree_snapshot.get("name", "Troubleshooting Session")) - html = ['', '', '', + html_parts = ['', '', '', '', f'{tree_name}', '