From e2088e499758f93c31d94ca22218bf5ab417bf75 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 13 Jul 2026 20:46:46 +0900 Subject: [PATCH 1/5] fix(auth): require an access-token type on the OIDC bearer path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OIDC verifier accepted any RS256 token whose signature, issuer, and audience matched, without checking token type. Since an OIDC ID token carries aud == client_id, a frontend ID token could be replayed as an API access token (RFC 8725 §3.11 / Strix HIGH, CVSS 8.8). naruon's API credential is the OIDC access token (the frontend sends token_response.access_token). Require the token to be marked as an access token — Keycloak body typ="Bearer" or RFC 9068 header typ="at+jwt" — and reject ID-token material (Keycloak typ="ID") and unmarked tokens. Regression tests replay an ID-token-shaped token and assert 401; they fail on the prior code and pass here. OIDC access-token fixtures updated to carry the realistic typ="Bearer". Supersedes #1077, whose inverse approach rejected scope-bearing Keycloak access tokens (breaking SSO login) while still admitting ID tokens. Co-Authored-By: Claude Fable 5 --- backend/api/auth.py | 36 ++++++++++++ backend/tests/test_auth_real.py | 101 ++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/backend/api/auth.py b/backend/api/auth.py index bd188351c..0da17fbb9 100644 --- a/backend/api/auth.py +++ b/backend/api/auth.py @@ -148,6 +148,15 @@ def _build_oidc_jwks_client() -> PyJWKClient | None: SESSION_ISSUER = "naruon-control-plane" SESSION_AUDIENCE = "naruon-api" JWT_DECODE_REQUIRED_CLAIMS = ("exp", "iss", "aud") +# An OIDC ID token carries aud == client_id, so verifying signature/issuer/aud +# alone lets a frontend ID token be replayed as an API access token (RFC 8725 +# §3.11). naruon's API credential is the OIDC access token (the frontend sends +# token_response.access_token). Accept only material the IdP marks as an access +# token: Keycloak sets the body claim typ="Bearer"; RFC 9068 sets the header +# typ "at+jwt". ID tokens (Keycloak typ="ID") and unmarked material are rejected. +# ponytail: extend these sets if a non-Keycloak/non-RFC9068 IdP is onboarded. +OIDC_ACCESS_TOKEN_HEADER_TYPE = "at+jwt" +OIDC_ACCESS_TOKEN_BODY_TYPES = frozenset({"bearer"}) MIN_SESSION_SECRET_BYTES = 32 MAX_SIGNED_SESSION_EXPIRATION_SECONDS = 12 * 60 * 60 MAX_SIGNED_SESSION_CLOCK_SKEW_SECONDS = 60 @@ -263,10 +272,37 @@ def _decode_cached_oidc_session_payload(token: str) -> dict[str, Any]: raise _authentication_error() if not isinstance(payload, dict): raise _authentication_error() + _require_oidc_access_token(header, payload) return payload raise _authentication_error() +def _require_oidc_access_token( + header: dict[str, Any], payload: dict[str, Any] +) -> None: + """Reject OIDC ID tokens replayed as API access tokens (RFC 8725 §3.11). + + The frontend transmits the OIDC access token as the API bearer, so only + material the IdP marks as an access token may build a session: an RFC 9068 + header typ of "at+jwt", or a Keycloak body typ of "Bearer". ID tokens + (Keycloak typ="ID") and tokens with no access-token marker are rejected even + when signature, issuer, and audience verify. + """ + header_type = header.get("typ") + if ( + isinstance(header_type, str) + and header_type.strip().lower() == OIDC_ACCESS_TOKEN_HEADER_TYPE + ): + return + body_type = payload.get("typ") + if ( + isinstance(body_type, str) + and body_type.strip().lower() in OIDC_ACCESS_TOKEN_BODY_TYPES + ): + return + raise _authentication_error() + + def _reject_unsupported_critical_headers(header: dict[str, Any]) -> None: if "crit" in header: raise _authentication_error() diff --git a/backend/tests/test_auth_real.py b/backend/tests/test_auth_real.py index 11450683e..bf37f49f2 100644 --- a/backend/tests/test_auth_real.py +++ b/backend/tests/test_auth_real.py @@ -972,6 +972,7 @@ def mock_jwt_decode(*args, **kwargs): return { "iss": "https://login.example.test/realms/naruon", "aud": "naruon-api", + "typ": "Bearer", "sub": "alice", "role": "member", "org": "org-acme", @@ -1026,6 +1027,7 @@ def mock_jwt_decode(*args, **kwargs): return { "iss": "https://login.example.test/realms/naruon", "aud": ("naruon-api", "naruon-admin"), + "typ": "Bearer", "sub": "alice", "role": "member", "org": "org-acme", @@ -1051,6 +1053,104 @@ def mock_jwt_decode(*args, **kwargs): assert context.user_id == "alice" +def _oidc_settings_snapshot(): + return ( + settings.OIDC_ISSUER_URL, + settings.OIDC_CLIENT_ID, + settings.AUTH_SESSION_HMAC_SECRET, + ) + + +def _apply_oidc_settings(): + settings.OIDC_ISSUER_URL = "https://login.example.test/realms/naruon" + settings.OIDC_CLIENT_ID = "naruon-api" + settings.AUTH_SESSION_HMAC_SECRET = SecretStr(TEST_SESSION_HMAC_SECRET) + + +def _restore_oidc_settings(previous): + ( + settings.OIDC_ISSUER_URL, + settings.OIDC_CLIENT_ID, + settings.AUTH_SESSION_HMAC_SECRET, + ) = previous + + +def _install_oidc_decode(monkeypatch, payload): + import jwt + + class MockKey: + key_id = "test-key" + key = "public_key" + + monkeypatch.setattr("api.auth.jwks_client", object()) + monkeypatch.setattr("api.auth._cached_oidc_signing_keys", (MockKey(),)) + monkeypatch.setattr(jwt, "decode", lambda *a, **k: payload) + + +def _oidc_claims(**overrides): + payload = { + "iss": "https://login.example.test/realms/naruon", + "aud": "naruon-api", + "sub": "alice", + "role": "member", + "org": "org-acme", + "groups": ["group-1", "group-2"], + "workspace": "workspace-org-acme", + "exp": int(time.time()) + 300, + } + payload.update(overrides) + return payload + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "header,claims", + [ + # Keycloak ID token replayed as an API bearer (the Strix finding). + ({"alg": "RS256", "typ": "JWT", "kid": "test-key"}, {"typ": "ID"}), + # Forged/opaque material with no access-token marker (the PoC shape). + ({"alg": "RS256", "typ": "JWT", "kid": "test-key"}, {}), + # An ID-token typ must not sneak through via the header either. + ({"alg": "RS256", "typ": "JWT", "kid": "test-key"}, {"typ": "id"}), + ], +) +async def test_oidc_rejects_id_token_replayed_as_api_bearer( + monkeypatch, header, claims +): + previous = _oidc_settings_snapshot() + _apply_oidc_settings() + _install_oidc_decode(monkeypatch, _oidc_claims(**claims)) + token = _signed_session_token(_valid_session_payload(), header=header) + + try: + with pytest.raises(HTTPException) as exc: + await get_auth_context(authorization=f"Bearer {token}") + finally: + _restore_oidc_settings(previous) + + assert exc.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_oidc_accepts_rfc9068_access_token_header_typ(monkeypatch): + previous = _oidc_settings_snapshot() + _apply_oidc_settings() + # RFC 9068 access token: marker in the header typ, no body typ claim. + _install_oidc_decode(monkeypatch, _oidc_claims()) + token = _signed_session_token( + _valid_session_payload(), + header={"alg": "RS256", "typ": "at+jwt", "kid": "test-key"}, + ) + + try: + context = await get_auth_context(authorization=f"Bearer {token}") + finally: + _restore_oidc_settings(previous) + + assert context.session_verifier == "oidc" + assert context.user_id == "alice" + + @pytest.mark.asyncio async def test_oidc_session_rejects_missing_client_id_after_decode(monkeypatch): import jwt @@ -1072,6 +1172,7 @@ class MockKey: def mock_jwt_decode(*args, **kwargs): return { "iss": "https://login.example.test/realms/naruon", + "typ": "Bearer", "sub": "alice", "role": "member", "org": "org-acme", From 2e13cf53dceeff822d037a66ac41603141c677d5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 13 Jul 2026 21:21:53 +0900 Subject: [PATCH 2/5] fix(security): classify RFC 9068 token type literal --- backend/api/auth.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/api/auth.py b/backend/api/auth.py index 0da17fbb9..43d5d387c 100644 --- a/backend/api/auth.py +++ b/backend/api/auth.py @@ -155,7 +155,8 @@ def _build_oidc_jwks_client() -> PyJWKClient | None: # token: Keycloak sets the body claim typ="Bearer"; RFC 9068 sets the header # typ "at+jwt". ID tokens (Keycloak typ="ID") and unmarked material are rejected. # ponytail: extend these sets if a non-Keycloak/non-RFC9068 IdP is onboarded. -OIDC_ACCESS_TOKEN_HEADER_TYPE = "at+jwt" +# Bandit B105 is inapplicable: this is RFC 9068's public typ identifier. +OIDC_ACCESS_TOKEN_HEADER_TYPE = "at+jwt" # nosec B105 OIDC_ACCESS_TOKEN_BODY_TYPES = frozenset({"bearer"}) MIN_SESSION_SECRET_BYTES = 32 MAX_SIGNED_SESSION_EXPIRATION_SECONDS = 12 * 60 * 60 From 0cefcdc2a791a4f0074a418802f2e2e205e29f34 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 13 Jul 2026 21:58:38 +0900 Subject: [PATCH 3/5] fix(security): clear central Semgrep baseline --- backend/tests/test_repo_hygiene.py | 8 ++++++++ docker-compose.infra.yml | 21 +++++++++++++++++++++ frontend/screenshot.cjs | 3 ++- 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/backend/tests/test_repo_hygiene.py b/backend/tests/test_repo_hygiene.py index 92b8ce05b..d84d3162b 100644 --- a/backend/tests/test_repo_hygiene.py +++ b/backend/tests/test_repo_hygiene.py @@ -131,6 +131,10 @@ def test_infra_compose_services_use_read_only_hardening_anchor(): "keycloak", ): assert f" {service}:\n <<: *service-hardening" in compose + service_block = compose.split(f" {service}:", 1)[1].split("\n\n ", 1)[0] + assert "security_opt:" in service_block + assert "- no-new-privileges:true" in service_block + assert "read_only: true" in service_block def test_screenshot_utility_allows_only_local_static_routes(): @@ -141,6 +145,10 @@ def test_screenshot_utility_allows_only_local_static_routes(): assert "ALLOWED_ROUTES.has(route)" in screenshot_script assert "new URL(route, SCREENSHOT_ORIGIN)" in screenshot_script assert "url.origin !== SCREENSHOT_ORIGIN" in screenshot_script + assert "routeUrl enforces a fixed loopback origin" in screenshot_script + assert "nosemgrep: javascript.playwright.security.audit.playwright-goto-injection" in ( + screenshot_script + ) assert "console.error('Failed to capture route'" in screenshot_script assert "http://localhost:3000${route}" not in screenshot_script assert "console.error(`Failed to capture ${route}:`" not in screenshot_script diff --git a/docker-compose.infra.yml b/docker-compose.infra.yml index 34dafb968..f6c23ff17 100644 --- a/docker-compose.infra.yml +++ b/docker-compose.infra.yml @@ -10,6 +10,9 @@ x-service-hardening: &service-hardening services: traefik: <<: *service-hardening + security_opt: + - no-new-privileges:true + read_only: true image: traefik:v2.10 command: - "--api.insecure=true" @@ -26,6 +29,9 @@ services: prometheus: <<: *service-hardening + security_opt: + - no-new-privileges:true + read_only: true image: prom/prometheus:latest tmpfs: - /prometheus @@ -39,6 +45,9 @@ services: grafana: <<: *service-hardening + security_opt: + - no-new-privileges:true + read_only: true image: grafana/grafana:latest environment: - GF_SECURITY_ADMIN_PASSWORD=admin @@ -55,6 +64,9 @@ services: loki: <<: *service-hardening + security_opt: + - no-new-privileges:true + read_only: true image: grafana/loki:2.9.2 tmpfs: - /loki @@ -67,6 +79,9 @@ services: tempo: <<: *service-hardening + security_opt: + - no-new-privileges:true + read_only: true image: grafana/tempo:latest command: [ "-config.file=/etc/tempo.yaml" ] volumes: @@ -79,6 +94,9 @@ services: otel-collector: <<: *service-hardening + security_opt: + - no-new-privileges:true + read_only: true image: otel/opentelemetry-collector:0.88.0 ports: - "4317:4317" # OTLP gRPC @@ -88,6 +106,9 @@ services: keycloak: <<: *service-hardening + security_opt: + - no-new-privileges:true + read_only: true image: quay.io/keycloak/keycloak:24.0.0 command: start-dev environment: diff --git a/frontend/screenshot.cjs b/frontend/screenshot.cjs index 8f640d8b6..83b8ae879 100644 --- a/frontend/screenshot.cjs +++ b/frontend/screenshot.cjs @@ -39,7 +39,8 @@ function routeUrl(route) { const url = routeUrl(route); console.log('Taking screenshot for route', route); try { - await page.goto(url, { waitUntil: 'load', timeout: 30000 }); + // routeUrl enforces a fixed loopback origin and an exact route allowlist. + await page.goto(url, { waitUntil: 'load', timeout: 30000 }); // nosemgrep: javascript.playwright.security.audit.playwright-goto-injection.playwright-goto-injection await page.waitForTimeout(2000); const name = route === '/' ? 'home' : route.slice(1); await page.screenshot({ path: `test-results/${name}-screenshot.png`, fullPage: true }); From dfda0f275ecf9f68cd1598de2522c37f553c6952 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 13 Jul 2026 22:16:56 +0900 Subject: [PATCH 4/5] fix(security): scope screenshot navigation evidence --- backend/tests/test_repo_hygiene.py | 6 ++++-- frontend/screenshot.cjs | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/tests/test_repo_hygiene.py b/backend/tests/test_repo_hygiene.py index d84d3162b..e4cdccd2b 100644 --- a/backend/tests/test_repo_hygiene.py +++ b/backend/tests/test_repo_hygiene.py @@ -146,8 +146,10 @@ def test_screenshot_utility_allows_only_local_static_routes(): assert "new URL(route, SCREENSHOT_ORIGIN)" in screenshot_script assert "url.origin !== SCREENSHOT_ORIGIN" in screenshot_script assert "routeUrl enforces a fixed loopback origin" in screenshot_script - assert "nosemgrep: javascript.playwright.security.audit.playwright-goto-injection" in ( - screenshot_script + assert ( + "// nosemgrep: javascript.playwright.security.audit.playwright-goto-injection." + "playwright-goto-injection\n await page.goto(url" + in screenshot_script ) assert "console.error('Failed to capture route'" in screenshot_script assert "http://localhost:3000${route}" not in screenshot_script diff --git a/frontend/screenshot.cjs b/frontend/screenshot.cjs index 83b8ae879..97f36c291 100644 --- a/frontend/screenshot.cjs +++ b/frontend/screenshot.cjs @@ -40,7 +40,8 @@ function routeUrl(route) { console.log('Taking screenshot for route', route); try { // routeUrl enforces a fixed loopback origin and an exact route allowlist. - await page.goto(url, { waitUntil: 'load', timeout: 30000 }); // nosemgrep: javascript.playwright.security.audit.playwright-goto-injection.playwright-goto-injection + // nosemgrep: javascript.playwright.security.audit.playwright-goto-injection.playwright-goto-injection + await page.goto(url, { waitUntil: 'load', timeout: 30000 }); await page.waitForTimeout(2000); const name = route === '/' ? 'home' : route.slice(1); await page.screenshot({ path: `test-results/${name}-screenshot.png`, fullPage: true }); From 51c7b931d4419954b9d68a258e20d37e5167a278 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 13 Jul 2026 22:22:15 +0900 Subject: [PATCH 5/5] chore(ci): replay centralized Semgrep gate