diff --git a/app/modules/core/rbac/ui.py b/app/modules/core/rbac/ui.py index 88e62d8..c616f98 100644 --- a/app/modules/core/rbac/ui.py +++ b/app/modules/core/rbac/ui.py @@ -2,6 +2,7 @@ from __future__ import annotations from fastapi import APIRouter, Form, Request from fastapi.responses import RedirectResponse +from app.core.http_responses import not_found_response from sqlalchemy import select from app.core.db.common import CommonSessionLocal @@ -17,6 +18,12 @@ from app.modules.core.rbac.services import build_permissions_payload, build_role router = APIRouter(prefix="/system-settings/rbac", tags=["rbac-ui"]) +def _csrf_rejected(request: Request): + from app.core.http_responses import forbidden_response + return forbidden_response(request, "CSRF validation failed") + + + def _redirect_login(): return RedirectResponse(url="/login", status_code=303) @@ -47,6 +54,15 @@ def rbac_dashboard(request: Request): return RedirectResponse(url="/system-settings/rbac/roles", status_code=303) + + +# Phase 3 security hardening: the roles list endpoint must not accept direct unsafe POSTs. +# Existing role creation continues to use /roles/new. +@router.post("/roles") +def roles_list_post_rejected(request: Request): + return _csrf_rejected(request) + + @router.get("/roles") def roles_list(request: Request, q: str = "", page: int = 1, per_page: int = 10): db = CommonSessionLocal() @@ -76,7 +92,10 @@ def role_create_submit( is_active: str | None = Form(None), csrf_token: str = Form(...), ): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -124,7 +143,7 @@ def role_permissions_edit(request: Request, role_id: int): role = db.execute(select(Role).where(Role.id == role_id)).scalar_one_or_none() if not role: - return RedirectResponse(url="/system-settings/rbac/roles", status_code=303) + return not_found_response(request, "Role not found") scope = build_scope(db, current_user) try: @@ -160,7 +179,10 @@ def role_permissions_submit( permission_ids: list[int] = Form([]), csrf_token: str = Form(...), ): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -172,7 +194,7 @@ def role_permissions_submit( role = db.execute(select(Role).where(Role.id == role_id)).scalar_one_or_none() if not role: - return RedirectResponse(url="/system-settings/rbac/roles", status_code=303) + return not_found_response(request, "Role not found") old_permission_ids = db.execute( select(RolePermission.permission_id).where(RolePermission.role_id == role.id) @@ -233,7 +255,10 @@ def permission_create_submit( is_active: str | None = Form(None), csrf_token: str = Form(...), ): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -265,4 +290,4 @@ def permission_create_submit( return RedirectResponse(url="/system-settings/rbac/permissions", status_code=303) finally: - db.close() \ No newline at end of file + db.close() diff --git a/app/modules/employees/ui.py b/app/modules/employees/ui.py index 8402112..e35ff26 100644 --- a/app/modules/employees/ui.py +++ b/app/modules/employees/ui.py @@ -135,6 +135,12 @@ from app.modules.employees.service import ( ) router = APIRouter(prefix="/employees", tags=["employees-ui"]) + + +def _csrf_rejected(request: Request): + from app.core.http_responses import forbidden_response + return forbidden_response(request, "CSRF validation failed") + portal_router = APIRouter(prefix="/employee", tags=["employee-portal-ui"]) @@ -416,7 +422,10 @@ def employee_new(request: Request): @router.post("/new") async def employee_create_submit(request: Request): form = await request.form() - validate_csrf(request, form.get("csrf_token")) + try: + validate_csrf(request, form.get("csrf_token")) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -447,6 +456,21 @@ async def employee_create_submit(request: Request): db.close() + + +# Phase 3 security hardening: list pages must not accept direct unsafe POSTs. +# Existing attendance and leave actions continue to use their dedicated action routes. +@router.post("/attendance") +def employee_attendance_list_post_rejected(request: Request): + return _csrf_rejected(request) + + +@router.post("/leaves") +@router.post("/leave") +def employee_leave_list_post_rejected(request: Request): + return _csrf_rejected(request) + + @router.get("/attendance") def employees_attendance_list( request: Request, @@ -506,7 +530,10 @@ def employees_attendance_manual_submit( remarks: str | None = Form(None), csrf_token: str = Form(...), ): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -541,7 +568,10 @@ def employees_attendance_review_submit( review_notes: str | None = Form(None), csrf_token: str = Form(...), ): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -583,7 +613,10 @@ def employee_leave_types(request: Request, include_inactive: str | None = None): @router.post("/leave-types/defaults") def employee_leave_type_defaults(request: Request, csrf_token: str = Form(...)): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -604,7 +637,10 @@ def employee_leave_type_defaults(request: Request, csrf_token: str = Form(...)): @router.post("/leave-types/new") def employee_leave_type_create(request: Request, code: str = Form(...), name: str = Form(...), description: str | None = Form(None), annual_quota_days: int = Form(0), carry_forward_allowed: str | None = Form(None), allow_negative_balance: str | None = Form(None), requires_approval: str | None = Form(None), is_paid: str | None = Form(None), is_active: str | None = Form(None), csrf_token: str = Form(...)): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -625,7 +661,10 @@ def employee_leave_type_create(request: Request, code: str = Form(...), name: st @router.post("/leave-types/{leave_type_id}/edit") def employee_leave_type_edit(request: Request, leave_type_id: int, name: str = Form(...), description: str | None = Form(None), annual_quota_days: int = Form(0), carry_forward_allowed: str | None = Form(None), allow_negative_balance: str | None = Form(None), requires_approval: str | None = Form(None), is_paid: str | None = Form(None), is_active: str | None = Form(None), csrf_token: str = Form(...)): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -669,7 +708,10 @@ def employee_leave_balances(request: Request, employee_id: int | None = None): @router.post("/leave-balances/adjust") def employee_leave_balance_adjust(request: Request, employee_id: int = Form(...), leave_type_id: int = Form(...), adjusted_days: int = Form(0), notes: str | None = Form(None), csrf_token: str = Form(...)): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -712,7 +754,10 @@ def employee_leave_requests(request: Request, employee_id: int | None = None, st @router.post("/leave/{request_id}/review") def employee_leave_review_submit(request: Request, request_id: int, status: str = Form(...), review_notes: str | None = Form(None), csrf_token: str = Form(...)): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -798,7 +843,10 @@ def employee_registration_request_approve( review_notes: str | None = Form(None), csrf_token: str = Form(...), ): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -825,7 +873,10 @@ def employee_registration_request_reject( review_notes: str | None = Form(None), csrf_token: str = Form(...), ): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -869,7 +920,10 @@ def employee_onboarding_checklist(request: Request): @router.post("/onboarding-checklist/defaults") def employee_onboarding_checklist_defaults(request: Request, csrf_token: str = Form(...)): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -891,7 +945,10 @@ def employee_onboarding_checklist_defaults(request: Request, csrf_token: str = F @router.post("/onboarding-checklist") async def employee_onboarding_checklist_create(request: Request): form = await request.form() - validate_csrf(request, form.get("csrf_token")) + try: + validate_csrf(request, form.get("csrf_token")) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -913,7 +970,10 @@ async def employee_onboarding_checklist_create(request: Request): @router.post("/onboarding-checklist/{item_id}/edit") async def employee_onboarding_checklist_edit(request: Request, item_id: int): form = await request.form() - validate_csrf(request, form.get("csrf_token")) + try: + validate_csrf(request, form.get("csrf_token")) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -954,7 +1014,10 @@ def employee_onboarding_tasks(request: Request, employee_id: int | None = None): @router.post("/{employee_id}/onboarding/generate") def employee_generate_onboarding(request: Request, employee_id: int, csrf_token: str = Form(...)): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -976,7 +1039,10 @@ def employee_generate_onboarding(request: Request, employee_id: int, csrf_token: @router.post("/onboarding/{task_id}/status") def employee_onboarding_task_status(request: Request, task_id: int, status: str = Form(...), review_notes: str | None = Form(None), csrf_token: str = Form(...)): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -1019,7 +1085,10 @@ def employee_offboarding_requests(request: Request, status: str | None = None): @router.post("/{employee_id}/offboarding/initiate") async def employee_offboarding_initiate(request: Request, employee_id: int): form = await request.form() - validate_csrf(request, form.get("csrf_token")) + try: + validate_csrf(request, form.get("csrf_token")) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -1041,7 +1110,10 @@ async def employee_offboarding_initiate(request: Request, employee_id: int): @router.post("/offboarding/{request_id}/review") def employee_offboarding_review(request: Request, request_id: int, status: str = Form(...), approved_relieving_date: str | None = Form(None), review_notes: str | None = Form(None), csrf_token: str = Form(...)): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -1063,7 +1135,10 @@ def employee_offboarding_review(request: Request, request_id: int, status: str = @router.post("/offboarding/tasks/{task_id}/status") def employee_offboarding_task_status(request: Request, task_id: int, status: str = Form(...), review_notes: str | None = Form(None), csrf_token: str = Form(...)): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -1085,7 +1160,10 @@ def employee_offboarding_task_status(request: Request, task_id: int, status: str @router.post("/offboarding/{request_id}/complete") def employee_offboarding_complete(request: Request, request_id: int, csrf_token: str = Form(...)): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -1127,7 +1205,10 @@ def employee_document_types(request: Request, include_inactive: str | None = Non @router.post("/document-types/defaults") def employee_document_types_defaults(request: Request, csrf_token: str = Form(...)): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -1149,7 +1230,10 @@ def employee_document_types_defaults(request: Request, csrf_token: str = Form(.. @router.post("/document-types") async def employee_document_type_create(request: Request): form = await request.form() - validate_csrf(request, form.get("csrf_token")) + try: + validate_csrf(request, form.get("csrf_token")) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -1174,7 +1258,10 @@ async def employee_document_type_create(request: Request): @router.post("/document-types/{type_id}/edit") async def employee_document_type_update(request: Request, type_id: int): form = await request.form() - validate_csrf(request, form.get("csrf_token")) + try: + validate_csrf(request, form.get("csrf_token")) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -1216,7 +1303,10 @@ def employee_documents(request: Request, employee_id: int | None = None, status: @router.post("/{employee_id}/documents/upload") async def employee_document_upload(request: Request, employee_id: int, document_file: UploadFile = File(...)): form = await request.form() - validate_csrf(request, form.get("csrf_token")) + try: + validate_csrf(request, form.get("csrf_token")) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -1258,7 +1348,10 @@ async def employee_document_upload(request: Request, employee_id: int, document_ @router.post("/documents/{document_id}/review") def employee_document_review(request: Request, document_id: int, status: str = Form(...), verification_notes: str | None = Form(None), csrf_token: str = Form(...)): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -1280,7 +1373,10 @@ def employee_document_review(request: Request, document_id: int, status: str = F @router.post("/documents/{document_id}/archive") def employee_document_archive(request: Request, document_id: int, notes: str | None = Form(None), csrf_token: str = Form(...)): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -1324,7 +1420,10 @@ def employee_self_documents(request: Request): @portal_router.post("/documents/upload") async def employee_self_document_upload(request: Request, document_file: UploadFile = File(...)): form = await request.form() - validate_csrf(request, form.get("csrf_token")) + try: + validate_csrf(request, form.get("csrf_token")) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -1398,7 +1497,10 @@ def employee_salary_structures(request: Request, employee_id: int | None = None) @router.post("/payroll/structures") async def employee_salary_structure_submit(request: Request): form = await request.form() - validate_csrf(request, form.get("csrf_token")) + try: + validate_csrf(request, form.get("csrf_token")) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -1425,7 +1527,10 @@ async def employee_salary_structure_submit(request: Request): @router.post("/payroll/structures/{structure_id}/update") async def employee_salary_structure_update(request: Request, structure_id: int): form = await request.form() - validate_csrf(request, form.get("csrf_token")) + try: + validate_csrf(request, form.get("csrf_token")) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -1468,7 +1573,10 @@ def employee_payroll_runs(request: Request): @router.post("/payroll/runs") def employee_payroll_run_create(request: Request, pay_year: int = Form(...), pay_month: int = Form(...), notes: str | None = Form(None), csrf_token: str = Form(...)): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -1489,7 +1597,10 @@ def employee_payroll_run_create(request: Request, pay_year: int = Form(...), pay @router.post("/payroll/runs/{run_id}/generate") def employee_payroll_run_generate(request: Request, run_id: int, csrf_token: str = Form(...)): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -1511,7 +1622,10 @@ def employee_payroll_run_generate(request: Request, run_id: int, csrf_token: str @router.post("/payroll/runs/{run_id}/approve") def employee_payroll_run_approve(request: Request, run_id: int, csrf_token: str = Form(...)): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -1533,7 +1647,10 @@ def employee_payroll_run_approve(request: Request, run_id: int, csrf_token: str @router.post("/payroll/runs/{run_id}/paid") def employee_payroll_run_paid(request: Request, run_id: int, csrf_token: str = Form(...)): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -1680,7 +1797,10 @@ def employee_work_task_assign( require_permission(db, current_user, "employees.work.manage") except Exception: return _redirect_denied() - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id branch_id = request.session.get("active_branch_id") scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) @@ -1720,7 +1840,10 @@ def employee_my_work_task_status( require_permission(db, current_user, "employees.work.view_self") except Exception: return _redirect_denied() - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id branch_id = request.session.get("active_branch_id") scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) @@ -1860,7 +1983,10 @@ def employee_work_task_add_communication( require_permission(db, current_user, "employees.work.manage") except Exception: return _redirect_denied() - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id branch_id = request.session.get("active_branch_id") scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) @@ -1935,7 +2061,10 @@ def employee_my_work_task_add_communication( require_permission(db, current_user, "employees.work.view_self") except Exception: return _redirect_denied() - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id branch_id = request.session.get("active_branch_id") scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) @@ -2026,7 +2155,10 @@ def employee_import_template(request: Request, import_type: str): @router.post("/imports/preview") async def employee_import_preview(request: Request, import_type: str = Form(...), import_file: UploadFile = File(...)): form = await request.form() - validate_csrf(request, form.get("csrf_token")) + try: + validate_csrf(request, form.get("csrf_token")) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -2069,7 +2201,10 @@ async def employee_import_preview(request: Request, import_type: str = Form(...) @router.post("/imports/commit") def employee_import_commit(request: Request, csrf_token: str = Form(...)): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -2162,7 +2297,10 @@ def employee_edit(request: Request, employee_id: int): @router.post("/{employee_id}/edit") async def employee_edit_submit(request: Request, employee_id: int): form = await request.form() - validate_csrf(request, form.get("csrf_token")) + try: + validate_csrf(request, form.get("csrf_token")) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -2199,7 +2337,10 @@ async def employee_edit_submit(request: Request, employee_id: int): @router.post("/{employee_id}/link-user") def employee_link_user_submit(request: Request, employee_id: int, user_id: str | None = Form(None), csrf_token: str = Form(...)): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -2231,7 +2372,10 @@ def employee_status_submit( date_of_leaving: str | None = Form(None), csrf_token: str = Form(...), ): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -2324,7 +2468,10 @@ def employee_punch_in_submit( accuracy_meters: str | None = Form(None), csrf_token: str = Form(...), ): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -2357,7 +2504,10 @@ def employee_punch_out_submit( accuracy_meters: str | None = Form(None), csrf_token: str = Form(...), ): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -2405,7 +2555,10 @@ def employee_self_leave(request: Request): @portal_router.post("/leave/apply") def employee_self_leave_apply(request: Request, leave_type_id: int = Form(...), from_date: str = Form(...), to_date: str = Form(...), reason: str | None = Form(None), csrf_token: str = Form(...)): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -2423,7 +2576,10 @@ def employee_self_leave_apply(request: Request, leave_type_id: int = Form(...), @portal_router.post("/leave/{request_id}/cancel") def employee_self_leave_cancel(request: Request, request_id: int, csrf_token: str = Form(...)): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -2461,7 +2617,10 @@ def employee_self_offboarding(request: Request): @portal_router.post("/offboarding/request") async def employee_self_offboarding_request(request: Request): form = await request.form() - validate_csrf(request, form.get("csrf_token")) + try: + validate_csrf(request, form.get("csrf_token")) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -2513,7 +2672,10 @@ def employee_self_profile(request: Request): @portal_router.post("/profile") async def employee_self_profile_submit(request: Request): form = await request.form() - validate_csrf(request, form.get("csrf_token")) + try: + validate_csrf(request, form.get("csrf_token")) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) @@ -2605,7 +2767,10 @@ def employee_registration_form(request: Request): @portal_router.post("/register") async def employee_registration_submit(request: Request): form = await request.form() - validate_csrf(request, form.get("csrf_token")) + try: + validate_csrf(request, form.get("csrf_token")) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: current_user = get_current_user(request, db=db) diff --git a/app/modules/marketplace/ui.py b/app/modules/marketplace/ui.py index 0f79a15..1ff90ac 100644 --- a/app/modules/marketplace/ui.py +++ b/app/modules/marketplace/ui.py @@ -34,6 +34,12 @@ router = APIRouter(prefix="/marketplace", tags=["marketplace-ui"]) public_router = APIRouter(tags=["marketplace-public-ui"]) +def _csrf_rejected(request: Request): + from app.core.http_responses import forbidden_response + return forbidden_response(request, "CSRF validation failed") + + + def _public_marketplace_ctx(request: Request, db, **ctx): @@ -135,7 +141,10 @@ def marketplace_public_request_service_submit(request: Request, csrf_token: str return RedirectResponse(url="/marketplace/public-lead", status_code=303) db = CommonSessionLocal() try: - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) lead = create_marketplace_lead( db, lead_name=lead_name, @@ -254,7 +263,10 @@ def lead_create(request: Request, csrf_token: str = Form(...), lead_name: str = user, response = _require_user(request, db, "marketplace_leads.create") if response: return response - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) lead = create_marketplace_lead(db, lead_name=lead_name, business_name=business_name, email=email, mobile=mobile, city=city, state=state, service_category=service_category, service_requested=service_requested, message=message, source=source, priority=priority, estimated_value=estimated_value, created_by_user_id=user.id) return RedirectResponse(url=f"/marketplace/leads/{lead.id}", status_code=303) finally: @@ -274,7 +286,10 @@ def public_lead_form(request: Request): def public_lead_submit(request: Request, csrf_token: str = Form(...), lead_name: str = Form(...), business_name: str = Form(""), email: str = Form(""), mobile: str = Form(""), city: str = Form(""), state: str = Form(""), service_category: str = Form(""), service_requested: str = Form(...), message: str = Form("")): db = CommonSessionLocal() try: - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) lead = create_marketplace_lead(db, lead_name=lead_name, business_name=business_name, email=email, mobile=mobile, city=city, state=state, service_category=service_category, service_requested=service_requested, message=message, source="public_website", priority="NORMAL") return _render(request, "modules/marketplace/templates/marketplace/public_thank_you.html", db, None, title="Thank You", lead=lead) finally: @@ -303,7 +318,10 @@ def lead_assign(request: Request, lead_id: int, csrf_token: str = Form(...), ten user, response = _require_user(request, db, "marketplace_leads.assign") if response: return response - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) lead = get_marketplace_lead(db, lead_id) if lead: assign_marketplace_lead(db, lead=lead, tenant_id=tenant_id, branch_id=branch_id or None, partner_user_id=partner_user_id or None, notes=notes, assigned_by_user_id=user.id) @@ -319,7 +337,10 @@ def lead_status_update(request: Request, lead_id: int, csrf_token: str = Form(.. user, response = _require_user(request, db, "marketplace_leads.update") if response: return response - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) lead = get_marketplace_lead(db, lead_id) if lead: update_lead_status(db, lead=lead, status=status, user_id=user.id) @@ -335,7 +356,10 @@ def lead_convert_client(request: Request, lead_id: int, csrf_token: str = Form(. user, response = _require_user(request, db, "marketplace_leads.convert") if response: return response - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) lead = get_marketplace_lead(db, lead_id) if lead: client = convert_lead_to_client(db, lead=lead, tenant_id=tenant_id, branch_id=branch_id, partner_user_id=partner_user_id or None, client_code=client_code, user_id=user.id) diff --git a/app/modules/notice_cases/ui.py b/app/modules/notice_cases/ui.py index 86ae645..9058955 100644 --- a/app/modules/notice_cases/ui.py +++ b/app/modules/notice_cases/ui.py @@ -46,6 +46,12 @@ from app.modules.core.tenancy.year_control import redirect_if_financial_year_loc router = APIRouter(prefix="/notice-cases", tags=["notice-cases-ui"]) +def _csrf_rejected(request: Request): + from app.core.http_responses import forbidden_response + return forbidden_response(request, "CSRF validation failed") + + + def _base_ctx(request: Request, user, db, **ctx): base = { "request": request, @@ -249,7 +255,10 @@ def case_create_submit( remarks: str = Form(""), csrf_token: str = Form(...), ): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: user, response = _require_user(request, db, "notice_cases.create") @@ -316,7 +325,10 @@ def case_edit_page(request: Request, case_id: int): @router.post("/{case_id}/edit") def case_edit_submit(request: Request, case_id: int, department: str = Form("GST"), case_type: str = Form("Notice"), title: str = Form(""), reference_no: str = Form(""), din_ack_no: str = Form(""), notice_date: str = Form(""), due_date: str = Form(""), financial_year: str = Form(""), assessment_year: str = Form(""), period_label: str = Form(""), status: str = Form("open"), priority: str = Form("normal"), assigned_partner_user_id: str = Form(""), assigned_manager_user_id: str = Form(""), assigned_staff_user_id: str = Form(""), issue_summary: str = Form(""), remarks: str = Form(""), csrf_token: str = Form(...)): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: user, response = _require_user(request, db, "notice_cases.edit") @@ -348,7 +360,10 @@ def case_edit_submit(request: Request, case_id: int, department: str = Form("GST @router.post("/{case_id}/events") def case_add_event(request: Request, case_id: int, event_type: str = Form("Internal Note"), event_date: str = Form(""), description: str = Form(""), next_due_date: str = Form(""), csrf_token: str = Form(...)): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: user, response = _require_user(request, db, "notice_cases.events.manage") @@ -368,7 +383,10 @@ def case_add_event(request: Request, case_id: int, event_type: str = Form("Inter @router.post("/{case_id}/hearings") def case_add_hearing(request: Request, case_id: int, hearing_date: str = Form(""), hearing_time: str = Form(""), venue_or_mode: str = Form(""), officer_name: str = Form(""), agenda: str = Form(""), outcome: str = Form(""), status: str = Form("scheduled"), csrf_token: str = Form(...)): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: user, response = _require_user(request, db, "notice_cases.hearings.manage") @@ -388,7 +406,10 @@ def case_add_hearing(request: Request, case_id: int, hearing_date: str = Form("" @router.post("/{case_id}/orders") def case_add_order(request: Request, case_id: int, order_type: str = Form("Other"), order_no: str = Form(""), order_date: str = Form(""), demand_amount: str = Form("0"), tax_amount: str = Form("0"), interest_amount: str = Form("0"), penalty_amount: str = Form("0"), summary: str = Form(""), appeal_due_date: str = Form(""), appeal_filed: str | None = Form(None), csrf_token: str = Form(...)): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: user, response = _require_user(request, db, "notice_cases.orders.manage") @@ -408,7 +429,10 @@ def case_add_order(request: Request, case_id: int, order_type: str = Form("Other @router.post("/{case_id}/documents/upload") def case_document_upload(request: Request, case_id: int, title: str = Form(""), document_type: str = Form("OTHER"), description: str = Form(""), event_id: str = Form(""), file: UploadFile = File(...), csrf_token: str = Form(...)): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: user, response = _require_user(request, db, "notice_cases.documents.upload") @@ -451,7 +475,10 @@ def case_document_download(request: Request, document_id: int): @router.post("/documents/{document_id}/delete") def case_document_delete(request: Request, document_id: int, csrf_token: str = Form(...)): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: user, response = _require_user(request, db, "notice_cases.documents.delete") diff --git a/app/modules/platform_billing/ui.py b/app/modules/platform_billing/ui.py index dce251a..3357617 100644 --- a/app/modules/platform_billing/ui.py +++ b/app/modules/platform_billing/ui.py @@ -48,6 +48,12 @@ from app.modules.platform_billing.services import ( router = APIRouter(prefix="/platform-billing", tags=["platform-billing-ui"]) +def _csrf_rejected(request: Request): + from app.core.http_responses import forbidden_response + return forbidden_response(request, "CSRF validation failed") + + + def _redirect_denied(): from app.core.http_responses import ui_access_denied return ui_access_denied() @@ -121,6 +127,45 @@ def dashboard(request: Request): db.close() + + +# Phase 3 security hardening: list pages must not accept direct unsafe POSTs. +# Existing create/update features continue to use their dedicated /new or action routes. +@router.post("/plans") +def plans_list_post_rejected(request: Request): + return _csrf_rejected(request) + + +@router.post("/accounts") +def accounts_list_post_rejected(request: Request): + return _csrf_rejected(request) + + +@router.post("/audit-firm-subscriptions") +def audit_firm_subscriptions_list_post_rejected(request: Request): + return _csrf_rejected(request) + + +@router.post("/client-dashboard-subscriptions") +def client_dashboard_subscriptions_list_post_rejected(request: Request): + return _csrf_rejected(request) + + +@router.post("/consultant-subscriptions") +def consultant_subscriptions_list_post_rejected(request: Request): + return _csrf_rejected(request) + + +@router.post("/subscriptions") +def subscriptions_list_post_rejected(request: Request): + return _csrf_rejected(request) + + +@router.post("/invoices") +def invoices_list_post_rejected(request: Request): + return _csrf_rejected(request) + + @router.get("/plans") def plans_list(request: Request, q: str = ""): db = CommonSessionLocal() @@ -152,7 +197,10 @@ def plan_create(request: Request, csrf_token: str = Form(...), code: str = Form( user, response = _require_user(request, db, "platform_plans.manage") if response: return response - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) create_platform_plan(db, code=code, name=name, target_account_type=target_account_type, billing_cycle=billing_cycle, base_amount=base_amount, gst_rate=gst_rate, description=description, feature_text=feature_text) return RedirectResponse(url="/platform-billing/plans", status_code=303) finally: @@ -190,7 +238,10 @@ def account_create(request: Request, csrf_token: str = Form(...), account_type: user, response = _require_user(request, db, "platform_billing.create") if response: return response - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) create_platform_account(db, account_type=account_type, account_code=account_code, display_name=display_name, tenant_id=int(tenant_id) if tenant_id else None, client_id=int(client_id) if client_id else None, consultant_id=int(consultant_id) if consultant_id else None, email=email, mobile=mobile, gstin=gstin, pan=pan, billing_address=billing_address, state=state, notes=notes, user_id=user.id) return RedirectResponse(url="/platform-billing/accounts", status_code=303) finally: @@ -228,7 +279,10 @@ def audit_firm_accounts_sync(request: Request, csrf_token: str = Form(...)): user, response = _require_user(request, db, "platform_subscriptions.manage") if response: return response - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) result = sync_audit_firm_billing_accounts(db, user_id=user.id) return RedirectResponse(url=f"/platform-billing/audit-firm-subscriptions?synced=created-{result['created']}-updated-{result['updated']}", status_code=303) finally: @@ -264,7 +318,10 @@ def audit_firm_generate_submit(request: Request, csrf_token: str = Form(...), su user, response = _require_user(request, db, "platform_billing.generate") if response: return response - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) result = generate_audit_firm_subscription_invoices( db, subscription_ids=subscription_ids, @@ -329,7 +386,10 @@ def client_dashboard_accounts_sync(request: Request, csrf_token: str = Form(...) user, response = _require_user(request, db, "platform_subscriptions.manage") if response: return response - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) result = sync_client_dashboard_billing_accounts(db, user_id=user.id) return RedirectResponse(url=f"/platform-billing/client-dashboard-subscriptions?synced=created-{result['created']}-updated-{result['updated']}", status_code=303) finally: @@ -343,7 +403,10 @@ def client_dashboard_subscription_create(request: Request, csrf_token: str = For user, response = _require_user(request, db, "platform_subscriptions.manage") if response: return response - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) create_platform_subscription( db, account_id=account_id, @@ -392,7 +455,10 @@ def client_dashboard_generate_submit(request: Request, csrf_token: str = Form(.. user, response = _require_user(request, db, "platform_billing.generate") if response: return response - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) result = generate_client_dashboard_subscription_invoices( db, subscription_ids=subscription_ids, @@ -456,7 +522,10 @@ def consultant_accounts_sync(request: Request, csrf_token: str = Form(...)): user, response = _require_user(request, db, "platform_subscriptions.manage") if response: return response - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) result = sync_consultant_billing_accounts(db, user_id=user.id) return RedirectResponse(url=f"/platform-billing/consultant-subscriptions?synced=created-{result['created']}-updated-{result['updated']}", status_code=303) finally: @@ -470,7 +539,10 @@ def consultant_subscription_create(request: Request, csrf_token: str = Form(...) user, response = _require_user(request, db, "platform_subscriptions.manage") if response: return response - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) create_platform_subscription( db, account_id=account_id, @@ -519,7 +591,10 @@ def consultant_generate_submit(request: Request, csrf_token: str = Form(...), su user, response = _require_user(request, db, "platform_billing.generate") if response: return response - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) result = generate_consultant_subscription_invoices( db, subscription_ids=subscription_ids, @@ -580,7 +655,10 @@ def subscription_create(request: Request, csrf_token: str = Form(...), account_i user, response = _require_user(request, db, "platform_subscriptions.manage") if response: return response - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) create_platform_subscription(db, account_id=account_id, plan_id=plan_id, subscription_code=subscription_code, start_date=parse_date(start_date), end_date=parse_date(end_date), billing_cycle=billing_cycle, amount=amount, gst_rate=gst_rate, auto_generate_invoice=auto_generate_invoice == "on", notes=notes, user_id=user.id) return RedirectResponse(url="/platform-billing/subscriptions", status_code=303) finally: @@ -618,7 +696,10 @@ def invoice_create(request: Request, csrf_token: str = Form(...), account_id: in user, response = _require_user(request, db, "platform_billing.create") if response: return response - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) invoice = create_platform_invoice( db, account_id=account_id, @@ -660,7 +741,10 @@ def invoice_post(request: Request, invoice_id: int, csrf_token: str = Form(...)) user, response = _require_user(request, db, "platform_billing.post") if response: return response - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) invoice = get_platform_invoice(db, invoice_id) if invoice and invoice.status == "DRAFT": post_platform_invoice(db, invoice, user.id) @@ -676,7 +760,10 @@ def payment_create(request: Request, invoice_id: int, csrf_token: str = Form(... user, response = _require_user(request, db, "platform_billing.payment.create") if response: return response - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) invoice = get_platform_invoice(db, invoice_id) if invoice: record_platform_payment(db, invoice=invoice, amount=amount, mode=mode, reference_no=reference_no, notes=notes, user_id=user.id) diff --git a/app/modules/system_settings/ui.py b/app/modules/system_settings/ui.py index 2520c23..ed47f87 100644 --- a/app/modules/system_settings/ui.py +++ b/app/modules/system_settings/ui.py @@ -25,6 +25,12 @@ from app.modules.system_settings.year_backup_service import build_year_backup_ex router = APIRouter(prefix="/system-settings", tags=["system-settings-ui"]) +def _csrf_rejected(request: Request): + from app.core.http_responses import forbidden_response + return forbidden_response(request, "CSRF validation failed") + + + def _base_ctx(request: Request, user, db, **ctx): base = { "request": request, @@ -172,6 +178,25 @@ def dashboard(request: Request): # ----------------------------- # Tenant - System Admin only # ----------------------------- + + +# Phase 3 security hardening: list/index pages must not accept direct unsafe POSTs. +# Existing create/update features continue to use their dedicated /new or action routes. +@router.post("/tenants") +def tenants_list_post_rejected(request: Request): + return _csrf_rejected(request) + + +@router.post("/branches") +def branches_list_post_rejected(request: Request): + return _csrf_rejected(request) + + +@router.post("/financial-years") +def financial_years_list_post_rejected(request: Request): + return _csrf_rejected(request) + + @router.get("/tenants") def tenants_list(request: Request, q: str = "", page: int = 1, per_page: int = 10): db = CommonSessionLocal() @@ -222,7 +247,10 @@ def tenant_create_submit( default_storage_mode: str = Form("local_only"), csrf_token: str = Form(...), ): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: user = get_current_user(request, db=db) @@ -296,7 +324,10 @@ def tenant_edit_submit( is_active: str | None = Form(None), csrf_token: str = Form(...), ): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: user = get_current_user(request, db=db) @@ -432,7 +463,10 @@ def branch_create_submit( attendance_allowed_ip_csv: str = Form(""), csrf_token: str = Form(...), ): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: user = get_current_user(request, db=db) @@ -627,7 +661,10 @@ def branch_edit_submit( lockout_attempts: str = Form("5"), lockout_minutes: str = Form("15"), ): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: user = get_current_user(request, db=db) @@ -881,7 +918,10 @@ async def branding_save( favicon_file: UploadFile | None = File(None), csrf_token: str = Form(...), ): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: user = get_current_user(request, db=db) @@ -1043,7 +1083,10 @@ def financial_year_create_submit( is_current: str | None = Form(None), csrf_token: str = Form(...), ): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: user = get_current_user(request, db=db) @@ -1140,7 +1183,10 @@ def financial_year_edit_submit( end_date: str = Form(...), csrf_token: str = Form(...), ): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: user = get_current_user(request, db=db) @@ -1182,7 +1228,10 @@ def financial_year_edit_submit( @router.post("/financial-years/{fy_id}/make-current") def financial_year_make_current(request: Request, fy_id: int, csrf_token: str = Form(...)): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: user = get_current_user(request, db=db) @@ -1209,7 +1258,10 @@ def financial_year_make_current(request: Request, fy_id: int, csrf_token: str = @router.post("/financial-years/{fy_id}/lock") def financial_year_lock(request: Request, fy_id: int, csrf_token: str = Form(...)): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: user = get_current_user(request, db=db) @@ -1232,7 +1284,10 @@ def financial_year_lock(request: Request, fy_id: int, csrf_token: str = Form(... @router.post("/financial-years/{fy_id}/unlock") def financial_year_unlock(request: Request, fy_id: int, csrf_token: str = Form(...)): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: user = get_current_user(request, db=db) @@ -1289,7 +1344,10 @@ def financial_year_backup_page(request: Request, fy_id: int): @router.post("/financial-years/{fy_id}/backup/export") def financial_year_backup_export_submit(request: Request, fy_id: int, csrf_token: str = Form(...)): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: user = get_current_user(request, db=db) diff --git a/app/modules/work_detail/ui.py b/app/modules/work_detail/ui.py index 5bcb2a8..f0fae27 100644 --- a/app/modules/work_detail/ui.py +++ b/app/modules/work_detail/ui.py @@ -14,6 +14,12 @@ from app.modules.work_detail.service import get_task_for_action, load_unified_en router = APIRouter(prefix="/work", tags=["unified-work-detail-ui"]) +def _csrf_rejected(request: Request): + from app.core.http_responses import forbidden_response + return forbidden_response(request, "CSRF validation failed") + + + def _base_ctx(request: Request, db, user, **ctx): base = { "request": request, @@ -56,6 +62,15 @@ def unified_work_board(request: Request): db.close() + + +# Phase 3 security hardening: /work/engagements is a listing alias, not a create endpoint. +# Existing engagement creation remains in the services module. +@router.post("/engagements") +def unified_engagements_post_rejected(request: Request): + return _csrf_rejected(request) + + @router.get("/engagements") def unified_engagements_index(request: Request): """Compatibility alias for /work/engagements. @@ -79,6 +94,8 @@ def unified_engagement_detail(request: Request, engagement_id: int): user = get_current_user(request, db=db) if not user: return RedirectResponse(url="/login", status_code=303) + if "Client" in set(get_user_roles(db, user.id)): + return forbidden_response(request, "Access denied") detail = load_unified_engagement_detail(db, request=request, user=user, engagement_id=engagement_id) if not detail: return not_found_response(request, "Work engagement not found or access denied") @@ -92,7 +109,10 @@ def unified_engagement_detail(request: Request, engagement_id: int): @router.post("/tasks/{task_id}/status") async def unified_task_status_update(request: Request, task_id: int, csrf_token: str = Form(...), status: str = Form(...), priority: str = Form("")): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: user = get_current_user(request, db=db) @@ -122,7 +142,10 @@ async def unified_task_comment_add( visibility: str = Form("internal"), message: str = Form(""), ): - validate_csrf(request, csrf_token) + try: + validate_csrf(request, csrf_token) + except PermissionError: + return _csrf_rejected(request) db = CommonSessionLocal() try: user = get_current_user(request, db=db)