From 54cc48d9c066792f6f420554ae759e9e3e32ad14 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 29 May 2026 23:26:16 +0900 Subject: [PATCH] Reject platform admin HMAC sessions --- AGENTS.md | 4 +++ backend/api/auth.py | 9 +++++- backend/tests/test_accounts_api.py | 8 ++--- backend/tests/test_auth_real.py | 25 ++++++++++++---- .../2026-05-29-hmac-system-admin-boundary.md | 29 +++++++++++++++++++ 5 files changed, 63 insertions(+), 12 deletions(-) create mode 100644 docs/plans/2026-05-29-hmac-system-admin-boundary.md diff --git a/AGENTS.md b/AGENTS.md index 0660e07bd..e3747fc11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,6 +112,10 @@ separate audited support flows; `/api/accounts/config` must reject forged or orgless privileged sessions before credential lookup, and tests must exercise the real signed bearer path rather than only dev public-header overrides. +- HMAC fallback sessions must not authorize `system_admin` or `platform_admin` + roles. Platform-wide operators require the OIDC/JWKS path or a separately + audited support flow so compromise of an HMAC session secret cannot mint + platform administrator claims. - Reply-wait task escalation must reuse the server-authoritative pending reply path, create or update source-linked `reply_sla` ticket tasks by opaque task id, and sanitize generated task titles from email subjects before persistence. diff --git a/backend/api/auth.py b/backend/api/auth.py index f919f941e..135aba344 100644 --- a/backend/api/auth.py +++ b/backend/api/auth.py @@ -233,7 +233,14 @@ def _verify_signed_session_payload(authorization: str | None) -> dict[str, Any]: if not hmac.compare_digest(expected_signature, provided_signature): raise _authentication_error() - return _json_object_from_base64url_segment(payload_segment) + payload = _json_object_from_base64url_segment(payload_segment) + _reject_hmac_system_admin_payload(payload) + return payload + + +def _reject_hmac_system_admin_payload(payload: dict[str, Any]) -> None: + if payload.get("role") in SYSTEM_ADMIN_ROLES: + raise _authentication_error() def _required_string_claim(payload: dict[str, Any], name: str) -> str: diff --git a/backend/tests/test_accounts_api.py b/backend/tests/test_accounts_api.py index 2fc63d610..25d1b3856 100644 --- a/backend/tests/test_accounts_api.py +++ b/backend/tests/test_accounts_api.py @@ -257,11 +257,9 @@ def test_accounts_config_rejects_system_admin_mailbox_owner_session(admin_role: {"smtp_server": "smtp.example.com", "smtp_port": 587}, ) - assert read_response.status_code == 403 - assert write_response.status_code == 403 - assert read_response.json()["detail"] == ( - "Mailbox account settings require a scoped user session" - ) + assert read_response.status_code == 401 + assert write_response.status_code == 401 + assert read_response.json()["detail"] == "Authentication required" assert session.execute_calls == 0 assert session.configs == {} diff --git a/backend/tests/test_auth_real.py b/backend/tests/test_auth_real.py index 98cc60023..99f04d3eb 100644 --- a/backend/tests/test_auth_real.py +++ b/backend/tests/test_auth_real.py @@ -474,7 +474,7 @@ async def test_admin_subject_does_not_imply_system_admin_role(): @pytest.mark.asyncio -async def test_system_admin_requires_explicit_signed_role_claim(): +async def test_hmac_session_rejects_platform_system_admin_role_claim(): settings.AUTH_SESSION_HMAC_SECRET = SecretStr(TEST_SESSION_HMAC_SECRET) token = _signed_session_token( _valid_session_payload( @@ -482,12 +482,25 @@ async def test_system_admin_requires_explicit_signed_role_claim(): ) ) - context = await get_auth_context(authorization=f"Bearer {token}") + with pytest.raises(HTTPException) as exc: + await get_auth_context(authorization=f"Bearer {token}") - assert context.user_id == "alice" - assert context.role == "system_admin" - assert context.organization_id is None - assert context.workspace_id == "workspace-root" + assert exc.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_hmac_session_rejects_platform_admin_role_claim(): + settings.AUTH_SESSION_HMAC_SECRET = SecretStr(TEST_SESSION_HMAC_SECRET) + token = _signed_session_token( + _valid_session_payload( + role="platform_admin", org=None, workspace="workspace-root" + ) + ) + + with pytest.raises(HTTPException) as exc: + await get_auth_context(authorization=f"Bearer {token}") + + assert exc.value.status_code == 401 def test_http_route_accepts_signed_bearer_and_ignores_forged_identity_headers(): diff --git a/docs/plans/2026-05-29-hmac-system-admin-boundary.md b/docs/plans/2026-05-29-hmac-system-admin-boundary.md new file mode 100644 index 000000000..7ccdfe062 --- /dev/null +++ b/docs/plans/2026-05-29-hmac-system-admin-boundary.md @@ -0,0 +1,29 @@ +# HMAC System Admin Boundary Roadmap + +## Evidence + +- Master Strix run `26641767705` reported a critical JWT authentication bypass: + if an attacker learns `AUTH_SESSION_HMAC_SECRET`, they can forge a + `system_admin` token. +- The runtime already validates HMAC secret length, fixture values, issuer, + audience, expiration, role names, and unsupported critical headers. +- The remaining high-impact path is platform-wide role minting through the + legacy HMAC fallback. + +## Plan + +1. Keep OIDC/JWKS verification authoritative when `OIDC_ISSUER_URL` is + configured. +2. Reject `system_admin` and `platform_admin` role claims on the legacy HMAC + fallback path. +3. Keep tenant-scoped HMAC sessions working for existing workspace flows. +4. Add signed bearer regression tests proving HMAC platform-admin claims fail. +5. Record the anti-pattern in `AGENTS.md` so future admin endpoints do not + reintroduce platform-wide HMAC sessions. + +## Non-Goals + +- This does not remove HMAC sessions for tenant-scoped local development or + existing non-platform workspace flows. +- This does not weaken OIDC; platform-wide roles should come from an external + IdP or an audited support flow.