From 8db957596456c17d077c190de954d49736600497 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 07:37:17 +0900 Subject: [PATCH 01/10] fix(auth): require issued-at in Keyverse OIDC sessions --- ARCHITECTURE.md | 9 ++-- CHANGELOG.md | 9 ++++ backend/api/auth.py | 20 +++++---- backend/tests/test_auth_real.py | 61 +++++++++++++++++++++++--- docs/operations/auth-key-management.md | 10 +++-- 5 files changed, 89 insertions(+), 20 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2139d7984..5eb05b0d3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -254,7 +254,9 @@ compact session tokens whose protected header pins `alg=HS256` and whose `header.payload` signing input is signed by the configured `AUTH_SESSION_HMAC_SECRET`; missing, weak, malformed, legacy two-segment, wrong-algorithm, tampered, expired, or public fixture-secret tokens fail closed -with 401. The signed session envelope must carry explicit identity, role, +with 401. OIDC tokens additionally require a verified `iat` NumericDate and an +exact configured issuer/client audience before their tenant and role claims are +used. The signed session envelope must carry explicit identity, role, organization/group, and workspace claims, so user ids such as `admin` do not imply elevated privileges. Endpoint tests use FastAPI dependency overrides for fixture identity only through @@ -277,8 +279,9 @@ provider endpoints additionally require an operator-owned egress allowlist so an organization admin cannot point LLM traffic at localhost, private networks, or cloud metadata services. -The browser API client reads `naruon_session_token` from local storage and sends -it as the bearer session on signed routes. It does not synthesize or forward +The browser API client uses the HttpOnly `naruon_session` cookie through the +same-origin `/api/*` proxy, which sends the verified bearer session on signed +routes. It does not read browser-readable bearer tokens or synthesize/forward public identity headers such as `X-User-Id`, `X-Organization-Id`, `X-Group-Id`, `X-Group-Ids`, `X-User-Role`, or `X-Dev-Auth-Token`; any local development identity-header flow is limited to explicit unsigned/test harness paths and is diff --git a/CHANGELOG.md b/CHANGELOG.md index c2e15635d..657ad5435 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,13 @@ ## [Unreleased] + +### 보안 패치 (Keyverse OIDC claim boundary) + +- Keyverse OIDC 세션은 이제 검증된 `iss`, `aud`, `iat`, `exp`, `sub`, `org`, + `workspace`, `role` 클레임을 모두 요구합니다. `naruon-web` audience와 + 조직/workspace 경계를 사용하는 명시적 acceptance 회귀 테스트를 추가해 + `iat`가 빠진 토큰이 HMAC 세션으로 우회되지 않고 401로 종료되는지 + 검증합니다. + ### 보안 패치 (CodeQL extended current-head) - `cryptography`를 `50.0.0`으로 갱신해 공격자 제공 PKCS#7 EnvelopedData 복호화 결과의 오류·타이밍 차이로 발생하는 Bleichenbacher oracle(`CVE-2026-69247`, `GHSA-g6cj-pr64-35w5`)을 제거하고, backend·uv lock·hash lock·Strix CI 의존성 증거를 같은 버전으로 동기화했습니다. Strix 잠금은 `google-cloud-aiplatform==1.160.0`의 `<7` 제약을 위반하던 `protobuf==7.35.1`을 이미 검증된 `6.33.6`으로 복구해 다시 해석·설치 가능하게 했습니다. diff --git a/backend/api/auth.py b/backend/api/auth.py index bd188351c..a26919097 100644 --- a/backend/api/auth.py +++ b/backend/api/auth.py @@ -148,6 +148,7 @@ def _build_oidc_jwks_client() -> PyJWKClient | None: SESSION_ISSUER = "naruon-control-plane" SESSION_AUDIENCE = "naruon-api" JWT_DECODE_REQUIRED_CLAIMS = ("exp", "iss", "aud") +OIDC_JWT_DECODE_REQUIRED_CLAIMS = (*JWT_DECODE_REQUIRED_CLAIMS, "iat") MIN_SESSION_SECRET_BYTES = 32 MAX_SIGNED_SESSION_EXPIRATION_SECONDS = 12 * 60 * 60 MAX_SIGNED_SESSION_CLOCK_SKEW_SECONDS = 60 @@ -194,13 +195,14 @@ async def get_auth_context( def build_auth_context(authorization: str | None = None) -> AuthContext: """ - Build runtime identity from verified signed session material. - - Client-supplied identity metadata is not authentication material. Only a - bearer token signed by the configured control-plane HMAC secret can supply - identity, role, organization, group, and workspace claims in the runtime - dependency path. Endpoint tests that need fixture identities must continue to - use explicit FastAPI dependency overrides. + Build runtime identity from a verified signed bearer session. + + Client-supplied identity metadata is not authentication material. A bearer + token must be verified by the configured OIDC/JWKS provider or the + control-plane HMAC secret before it can supply identity, role, organization, + group, and workspace claims in the runtime dependency path. Endpoint tests + that need fixture identities must continue to use explicit FastAPI + dependency overrides. """ payload, session_verifier = _verify_signed_session_payload(authorization) return _auth_context_from_session_payload(payload, session_verifier) @@ -253,7 +255,7 @@ def _decode_cached_oidc_session_payload(token: str) -> dict[str, Any]: audience=settings.OIDC_CLIENT_ID, issuer=settings.OIDC_ISSUER_URL, options={ - "require": JWT_DECODE_REQUIRED_CLAIMS, + "require": OIDC_JWT_DECODE_REQUIRED_CLAIMS, "verify_signature": True, }, ) @@ -491,6 +493,8 @@ def _validate_session_metadata( if expires_at > now + MAX_SIGNED_SESSION_EXPIRATION_SECONDS: raise _authentication_error() issued_at = payload.get("iat") + if session_verifier == "oidc" and issued_at is None: + raise _authentication_error() if issued_at is not None: if isinstance(issued_at, bool) or not isinstance(issued_at, (int, float)): raise _authentication_error() diff --git a/backend/tests/test_auth_real.py b/backend/tests/test_auth_real.py index 11450683e..10606968c 100644 --- a/backend/tests/test_auth_real.py +++ b/backend/tests/test_auth_real.py @@ -946,14 +946,14 @@ def close(self) -> None: @pytest.mark.asyncio -async def test_signed_bearer_session_with_oidc(monkeypatch): +async def test_keyverse_oidc_session_with_verified_claims(monkeypatch): import jwt previous_issuer_url = settings.OIDC_ISSUER_URL previous_client_id = settings.OIDC_CLIENT_ID previous_secret = settings.AUTH_SESSION_HMAC_SECRET - settings.OIDC_ISSUER_URL = "https://login.example.test/realms/naruon" - settings.OIDC_CLIENT_ID = "naruon-api" + settings.OIDC_ISSUER_URL = "https://keyverse.example.test/realms/cwl" + settings.OIDC_CLIENT_ID = "naruon-web" settings.AUTH_SESSION_HMAC_SECRET = SecretStr(TEST_SESSION_HMAC_SECRET) class MockKey: @@ -970,14 +970,15 @@ def mock_jwt_decode(*args, **kwargs): decode_algorithms.append(list(kwargs["algorithms"])) decode_options.append(dict(kwargs["options"])) return { - "iss": "https://login.example.test/realms/naruon", - "aud": "naruon-api", + "iss": "https://keyverse.example.test/realms/cwl", + "aud": "naruon-web", "sub": "alice", "role": "member", "org": "org-acme", "groups": ["group-1", "group-2"], "workspace": "workspace-org-acme", "exp": int(time.time()) + 300, + "iat": int(time.time()), "_session_verifier": "hmac", } @@ -1000,10 +1001,57 @@ def mock_jwt_decode(*args, **kwargs): assert context.session_verifier == "oidc" assert decode_algorithms == [["RS256"]] assert decode_options == [ - {"require": ("exp", "iss", "aud"), "verify_signature": True} + {"require": ("exp", "iss", "aud", "iat"), "verify_signature": True} ] +@pytest.mark.asyncio +async def test_keyverse_oidc_session_rejects_missing_issued_at(monkeypatch): + import jwt + + previous_issuer_url = settings.OIDC_ISSUER_URL + previous_client_id = settings.OIDC_CLIENT_ID + previous_secret = settings.AUTH_SESSION_HMAC_SECRET + settings.OIDC_ISSUER_URL = "https://keyverse.example.test/realms/cwl" + settings.OIDC_CLIENT_ID = "naruon-web" + settings.AUTH_SESSION_HMAC_SECRET = SecretStr(TEST_SESSION_HMAC_SECRET) + + class MockKey: + key_id = "keyverse-key" + key = "public_key" + + monkeypatch.setattr("api.auth.jwks_client", object()) + monkeypatch.setattr("api.auth._cached_oidc_signing_keys", (MockKey(),)) + + def mock_jwt_decode(*args, **kwargs): + return { + "iss": "https://keyverse.example.test/realms/cwl", + "aud": "naruon-web", + "sub": "alice", + "role": "member", + "org": "org-acme", + "groups": ["group-1"], + "workspace": "workspace-org-acme", + "exp": int(time.time()) + 300, + } + + monkeypatch.setattr(jwt, "decode", mock_jwt_decode) + token = _signed_session_token( + _valid_session_payload(), + header={"alg": "RS256", "typ": "JWT", "kid": "keyverse-key"}, + ) + + try: + with pytest.raises(HTTPException) as exc: + await get_auth_context(authorization=f"Bearer {token}") + finally: + settings.OIDC_ISSUER_URL = previous_issuer_url + settings.OIDC_CLIENT_ID = previous_client_id + settings.AUTH_SESSION_HMAC_SECRET = previous_secret + + assert exc.value.status_code == 401 + + @pytest.mark.asyncio async def test_oidc_session_accepts_tuple_audience(monkeypatch): import jwt @@ -1032,6 +1080,7 @@ def mock_jwt_decode(*args, **kwargs): "groups": ["group-1", "group-2"], "workspace": "workspace-org-acme", "exp": int(time.time()) + 300, + "iat": int(time.time()), } monkeypatch.setattr(jwt, "decode", mock_jwt_decode) diff --git a/docs/operations/auth-key-management.md b/docs/operations/auth-key-management.md index 63b2d59e0..418eabe98 100644 --- a/docs/operations/auth-key-management.md +++ b/docs/operations/auth-key-management.md @@ -121,9 +121,13 @@ private-address resolution and DNS-rebinding bypasses. Development HTTP is limited to exact `localhost`, `127.0.0.1`, or `::1` loopback endpoints. - Browser-side OIDC support does not mint local roles. The IdP token must still - satisfy the backend's signed claim contract: verified issuer/audience, subject, - explicit non-platform role, organization, groups, workspace, expiry, and no - unsupported critical headers. + satisfy the backend's signed claim contract: verified issuer, configured + audience, subject, `iat` and `exp` NumericDate values, explicit non-platform + role, organization, groups, workspace, and no unsupported critical headers. +- For a Keyverse deployment, configure the exact Keyverse issuer and JWKS URL, + the reviewed `naruon-web` audience, and the operator-owned OIDC host allowlist + together. The verified `org`, `workspace`, and `role` claims are inputs to + Naruon's deny-first ABAC/RBAC policy; their presence alone never grants access. ## Keycloak/Casdoor decision path From 6a5cf11902bccbbdacf16901d8cd9b7133eb7d49 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 07:49:45 +0900 Subject: [PATCH 02/10] fix(security): strip orphaned html comment terminators --- CHANGELOG.md | 3 +++ backend/services/text_safety.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 657ad5435..2c8614bae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ 조직/workspace 경계를 사용하는 명시적 acceptance 회귀 테스트를 추가해 `iat`가 빠진 토큰이 HMAC 세션으로 우회되지 않고 401로 종료되는지 검증합니다. +- HTML 텍스트 정제기가 비정상 주석 입력에서 남길 수 있던 고립된 `-->` + 종료자를 제거해 브라우저 태그 유사 payload가 정제 결과로 재출력되지 + 않도록 보강했습니다. ### 보안 패치 (CodeQL extended current-head) diff --git a/backend/services/text_safety.py b/backend/services/text_safety.py index d468a7d2f..7f557d054 100644 --- a/backend/services/text_safety.py +++ b/backend/services/text_safety.py @@ -460,7 +460,7 @@ def strip_html_markup(value: str) -> str: cleaned_lines = [] for line in text.splitlines(): cleaned_lines.append(_strip_tag_like_segments(line)) - text = "\n".join(cleaned_lines).strip() + text = "\n".join(cleaned_lines).replace("-->", "").strip() for token, original in placeholders.items(): text = text.replace(token, original) From 2a8afe3831b3d95f33d1825093320a20adaa855f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 13:30:22 +0900 Subject: [PATCH 03/10] test(auth): reproduce unique-token throttle bypass --- backend/tests/test_auth_http_rate_limit.py | 43 ++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 backend/tests/test_auth_http_rate_limit.py diff --git a/backend/tests/test_auth_http_rate_limit.py b/backend/tests/test_auth_http_rate_limit.py new file mode 100644 index 000000000..58a3d1b53 --- /dev/null +++ b/backend/tests/test_auth_http_rate_limit.py @@ -0,0 +1,43 @@ +from fastapi.testclient import TestClient + +from api import auth as auth_module +from api.auth import get_auth_context, get_current_user +from main import app + + +def test_http_auth_limits_varying_invalid_tokens_within_one_client_scope(monkeypatch): + """A single HTTP client scope must share one invalid-session attempt budget.""" + original_overrides = dict(app.dependency_overrides) + app.dependency_overrides.pop(get_auth_context, None) + app.dependency_overrides.pop(get_current_user, None) + auth_module._session_auth_failure_buckets.clear() + monkeypatch.setattr( + auth_module, + "SESSION_AUTH_SCOPE_RATE_LIMIT_MAX_FAILURES", + 3, + raising=False, + ) + decode_attempts = 0 + + def reject_header(token: str): + nonlocal decode_attempts + decode_attempts += 1 + raise auth_module.jwt.PyJWTError("invalid") + + monkeypatch.setattr(auth_module.jwt, "get_unverified_header", reject_header) + + try: + with TestClient(app, raise_server_exceptions=False) as client: + for index in range(4): + response = client.get( + "/api/auth/session", + headers={"Authorization": f"Bearer invalid.jwt.token-{index}"}, + ) + assert response.status_code == 401 + assert response.json() == {"detail": "Authentication required"} + finally: + app.dependency_overrides.clear() + app.dependency_overrides.update(original_overrides) + auth_module._session_auth_failure_buckets.clear() + + assert decode_attempts == 3 From 8be5ecad5266a0259915f50d82a2f28340d5a87f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 16:54:41 +0900 Subject: [PATCH 04/10] fix(auth): aggregate invalid-session failures by HTTP peer --- backend/api/auth.py | 89 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 77 insertions(+), 12 deletions(-) diff --git a/backend/api/auth.py b/backend/api/auth.py index a26919097..06dfa8dac 100644 --- a/backend/api/auth.py +++ b/backend/api/auth.py @@ -12,7 +12,7 @@ import jwt from jwt import PyJWKClient -from fastapi import Depends, Header, HTTPException +from fastapi import Depends, Header, HTTPException, Request from core.config import settings, validate_auth_session_hmac_secret_value from core.url_validation import ( @@ -154,6 +154,7 @@ def _build_oidc_jwks_client() -> PyJWKClient | None: MAX_SIGNED_SESSION_CLOCK_SKEW_SECONDS = 60 SESSION_AUTH_RATE_LIMIT_WINDOW_SECONDS = 60 SESSION_AUTH_RATE_LIMIT_MAX_FAILURES = 10 +SESSION_AUTH_SCOPE_RATE_LIMIT_MAX_FAILURES = 100 SESSION_AUTH_RATE_LIMIT_MAX_BUCKETS = 4096 _session_auth_failure_buckets: dict[str, tuple[int, float]] = {} @@ -189,8 +190,22 @@ def is_admin_role(role: str) -> bool: async def get_auth_context( authorization: Annotated[str | None, Header(alias="Authorization")] = None, + request: Request = None, ) -> AuthContext: - return build_auth_context(authorization=authorization) + """Build HTTP identity while applying aggregate invalid-session throttling. + + The aggregate scope comes only from the ASGI peer address exposed by + ``request.client``. Application code deliberately ignores Forwarded and + X-Forwarded-For so arbitrary request headers cannot mint fresh throttle + identities. Operators behind a trusted reverse proxy must configure that + proxy/server boundary so the ASGI client address has the intended meaning. + """ + failure_scope = _http_session_auth_failure_scope(request) + payload, session_verifier = _verify_signed_session_payload( + authorization, + failure_scope=failure_scope, + ) + return _auth_context_from_session_payload(payload, session_verifier) def build_auth_context(authorization: str | None = None) -> AuthContext: @@ -203,6 +218,11 @@ def build_auth_context(authorization: str | None = None) -> AuthContext: group, and workspace claims in the runtime dependency path. Endpoint tests that need fixture identities must continue to use explicit FastAPI dependency overrides. + + This direct non-HTTP entry point intentionally has no peer-derived aggregate + rate-limit scope; it retains the exact-token failure budget only. HTTP + callers should use ``get_auth_context`` so varying invalid tokens from one + observed peer share an additional coarse abuse budget. """ payload, session_verifier = _verify_signed_session_payload(authorization) return _auth_context_from_session_payload(payload, session_verifier) @@ -304,6 +324,21 @@ def _session_auth_failure_key(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() +def _session_auth_scope_failure_key(scope: str) -> str: + digest = hashlib.sha256(scope.encode("utf-8")).hexdigest() + return f"scope:{digest}" + + +def _http_session_auth_failure_scope(request: Request | None) -> str | None: + """Return the server-observed HTTP peer scope without trusting headers.""" + if request is None: + return None + client = request.client + if client is None or not client.host: + return "peer:unavailable" + return f"peer:{client.host}" + + def _prune_session_auth_failure_buckets(now: float) -> None: expired_keys = [ key @@ -323,22 +358,34 @@ def _ensure_session_auth_failure_bucket_capacity(key: str) -> None: _session_auth_failure_buckets.pop(next(iter(_session_auth_failure_buckets)), None) -def _reject_if_session_auth_rate_limited(token: str) -> None: - now = time.monotonic() - _prune_session_auth_failure_buckets(now) - key = _session_auth_failure_key(token) +def _session_auth_failure_count(key: str, now: float) -> int: failure_count, _reset_at = _session_auth_failure_buckets.get( key, (0, now + SESSION_AUTH_RATE_LIMIT_WINDOW_SECONDS), ) - if failure_count >= SESSION_AUTH_RATE_LIMIT_MAX_FAILURES: - raise _authentication_error() + return failure_count -def _record_session_auth_failure(token: str) -> None: +def _reject_if_session_auth_rate_limited( + token: str, + failure_scope: str | None = None, +) -> None: now = time.monotonic() _prune_session_auth_failure_buckets(now) - key = _session_auth_failure_key(token) + token_key = _session_auth_failure_key(token) + if _session_auth_failure_count(token_key, now) >= SESSION_AUTH_RATE_LIMIT_MAX_FAILURES: + raise _authentication_error() + if failure_scope is None: + return + scope_key = _session_auth_scope_failure_key(failure_scope) + if ( + _session_auth_failure_count(scope_key, now) + >= SESSION_AUTH_SCOPE_RATE_LIMIT_MAX_FAILURES + ): + raise _authentication_error() + + +def _increment_session_auth_failure(key: str, now: float) -> None: _ensure_session_auth_failure_bucket_capacity(key) failure_count, reset_at = _session_auth_failure_buckets.get( key, @@ -350,20 +397,38 @@ def _record_session_auth_failure(token: str) -> None: _session_auth_failure_buckets[key] = (failure_count + 1, reset_at) +def _record_session_auth_failure( + token: str, + failure_scope: str | None = None, +) -> None: + now = time.monotonic() + _prune_session_auth_failure_buckets(now) + _increment_session_auth_failure(_session_auth_failure_key(token), now) + if failure_scope is not None: + _increment_session_auth_failure( + _session_auth_scope_failure_key(failure_scope), + now, + ) + + def _clear_session_auth_failures(token: str) -> None: + # Successful verification clears only the exact-token failure bucket. The + # coarse HTTP peer bucket is an abuse signal, not subscriber identity, and + # expires naturally so possession of one valid token cannot reset it. _session_auth_failure_buckets.pop(_session_auth_failure_key(token), None) def _verify_signed_session_payload( authorization: str | None, + failure_scope: str | None = None, ) -> tuple[dict[str, Any], SessionVerifier]: token = _extract_bearer_token(authorization) - _reject_if_session_auth_rate_limited(token) + _reject_if_session_auth_rate_limited(token, failure_scope) try: payload, session_verifier = _verify_signed_session_token(token) except HTTPException: - _record_session_auth_failure(token) + _record_session_auth_failure(token, failure_scope) raise _clear_session_auth_failures(token) return payload, session_verifier From c7ab7960206782e525d5c3bd2edb260c90f5b46c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 16:55:44 +0900 Subject: [PATCH 05/10] test(auth): cover aggregate failure scope boundaries --- backend/tests/test_auth_http_rate_limit.py | 109 ++++++++++++++++++++- 1 file changed, 107 insertions(+), 2 deletions(-) diff --git a/backend/tests/test_auth_http_rate_limit.py b/backend/tests/test_auth_http_rate_limit.py index 58a3d1b53..7e6abcb49 100644 --- a/backend/tests/test_auth_http_rate_limit.py +++ b/backend/tests/test_auth_http_rate_limit.py @@ -1,3 +1,4 @@ +import pytest from fastapi.testclient import TestClient from api import auth as auth_module @@ -15,7 +16,6 @@ def test_http_auth_limits_varying_invalid_tokens_within_one_client_scope(monkeyp auth_module, "SESSION_AUTH_SCOPE_RATE_LIMIT_MAX_FAILURES", 3, - raising=False, ) decode_attempts = 0 @@ -31,7 +31,11 @@ def reject_header(token: str): for index in range(4): response = client.get( "/api/auth/session", - headers={"Authorization": f"Bearer invalid.jwt.token-{index}"}, + headers={ + "Authorization": f"Bearer invalid.jwt.token-{index}", + "Forwarded": f"for=198.51.100.{index}", + "X-Forwarded-For": f"198.51.100.{index}", + }, ) assert response.status_code == 401 assert response.json() == {"detail": "Authentication required"} @@ -41,3 +45,104 @@ def reject_header(token: str): auth_module._session_auth_failure_buckets.clear() assert decode_attempts == 3 + + +def test_session_auth_aggregate_failure_scopes_are_isolated(monkeypatch): + """Independent server-observed peer scopes retain independent abuse budgets.""" + auth_module._session_auth_failure_buckets.clear() + monkeypatch.setattr( + auth_module, + "SESSION_AUTH_SCOPE_RATE_LIMIT_MAX_FAILURES", + 1, + ) + verification_attempts: list[str] = [] + + def reject_token(token: str): + verification_attempts.append(token) + raise auth_module._authentication_error() + + monkeypatch.setattr(auth_module, "_verify_signed_session_token", reject_token) + + with pytest.raises(auth_module.HTTPException): + auth_module._verify_signed_session_payload( + "Bearer invalid-a-1", + failure_scope="peer:scope-a", + ) + with pytest.raises(auth_module.HTTPException): + auth_module._verify_signed_session_payload( + "Bearer invalid-a-2", + failure_scope="peer:scope-a", + ) + with pytest.raises(auth_module.HTTPException): + auth_module._verify_signed_session_payload( + "Bearer invalid-b-1", + failure_scope="peer:scope-b", + ) + + assert verification_attempts == ["invalid-a-1", "invalid-b-1"] + + +def test_valid_session_does_not_reset_aggregate_failure_scope(monkeypatch): + """A valid bearer token cannot reset the coarse peer abuse-control budget.""" + auth_module._session_auth_failure_buckets.clear() + monkeypatch.setattr( + auth_module, + "SESSION_AUTH_SCOPE_RATE_LIMIT_MAX_FAILURES", + 2, + ) + verification_attempts: list[str] = [] + + def verify_token(token: str): + verification_attempts.append(token) + if token == "valid-token": + return {"role": "member"}, "hmac" + raise auth_module._authentication_error() + + monkeypatch.setattr(auth_module, "_verify_signed_session_token", verify_token) + + with pytest.raises(auth_module.HTTPException): + auth_module._verify_signed_session_payload( + "Bearer invalid-1", + failure_scope="peer:shared", + ) + payload, verifier = auth_module._verify_signed_session_payload( + "Bearer valid-token", + failure_scope="peer:shared", + ) + with pytest.raises(auth_module.HTTPException): + auth_module._verify_signed_session_payload( + "Bearer invalid-2", + failure_scope="peer:shared", + ) + with pytest.raises(auth_module.HTTPException): + auth_module._verify_signed_session_payload( + "Bearer invalid-3", + failure_scope="peer:shared", + ) + + assert payload == {"role": "member"} + assert verifier == "hmac" + assert verification_attempts == ["invalid-1", "valid-token", "invalid-2"] + + +def test_direct_auth_context_keeps_exact_token_only_failure_budget(monkeypatch): + """The non-HTTP verifier stays independent of peer-scoped throttling.""" + auth_module._session_auth_failure_buckets.clear() + monkeypatch.setattr( + auth_module, + "SESSION_AUTH_SCOPE_RATE_LIMIT_MAX_FAILURES", + 1, + ) + verification_attempts: list[str] = [] + + def reject_token(token: str): + verification_attempts.append(token) + raise auth_module._authentication_error() + + monkeypatch.setattr(auth_module, "_verify_signed_session_token", reject_token) + + for token in ("direct-invalid-1", "direct-invalid-2"): + with pytest.raises(auth_module.HTTPException): + auth_module._verify_signed_session_payload(f"Bearer {token}") + + assert verification_attempts == ["direct-invalid-1", "direct-invalid-2"] From d09b6b651c0f4cb051daab12d6aebe3c236cdeb4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 16:56:31 +0900 Subject: [PATCH 06/10] docs(auth): record aggregate throttle trust boundary --- docs/doctoring/http-session-throttling.md | 41 +++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 docs/doctoring/http-session-throttling.md diff --git a/docs/doctoring/http-session-throttling.md b/docs/doctoring/http-session-throttling.md new file mode 100644 index 000000000..de187f5fb --- /dev/null +++ b/docs/doctoring/http-session-throttling.md @@ -0,0 +1,41 @@ +# HTTP invalid-session throttling evidence + +## Decision + +Naruon's HTTP authentication boundary applies two bounded failed-verification budgets: + +1. an exact-token budget keyed by a SHA-256 digest of the bearer token; and +2. a coarser peer budget keyed by a SHA-256 digest of the server-observed ASGI `request.client.host` value. + +The application does not derive the peer budget from `Forwarded`, `X-Forwarded-For`, or other caller-controlled forwarding headers. Deployments behind a trusted reverse proxy must configure the proxy/server boundary so the ASGI client address has the intended operational meaning. The peer budget is deliberately looser than the exact-token budget because NAT gateways and reverse proxies can make many legitimate users share one observed peer. + +A failed HTTP verification increments both budgets. A successful verification clears only the exact-token failure bucket. The peer bucket expires naturally and is not reset by possession of a valid token, preventing a valid session from becoming an attacker-controlled reset primitive. Direct non-HTTP `build_auth_context()` calls retain only the exact-token budget because no trustworthy HTTP peer scope exists at that boundary. + +Both key families share the existing bounded in-memory bucket store, expiry window, and capacity limit. This coarse peer signal is defense in depth against varying invalid tokens; it is not a substitute for subscriber/authenticator-specific controls, identity-provider throttling, network perimeter controls, or tenant authorization. + +## Evidence and interpretation + +NIST SP 800-63B-4 requires verifiers to implement controls against online guessing and explicitly identifies IP address, geolocation, timing, and browser metadata as signals that can inform adaptive protections. Naruon uses the server-observed peer address only as one coarse abuse signal and keeps its existing cryptographic verification and exact-token budget. Because this peer signal can represent multiple users behind NAT or a reverse proxy, the product applies a higher threshold and bounded expiry rather than treating the address as subscriber identity. + +RFC 7519 defines JWT bearer-token claims and NumericDate semantics but does not make unsigned or unverified token contents trustworthy identity. Naruon therefore does not use an unverified `sub`, `iss`, `aud`, or other JWT claim to choose the aggregate throttle identity. OIDC issuer and audience validation remains a separate post-signature trust boundary. + +OpenID Connect Core requires the ID Token issuer to exactly match the issuer identifier and requires the relying party's `client_id` to be present in the `aud` claim; `aud` may be multi-valued. Naruon's Keyverse OIDC path therefore keeps exact issuer validation while accepting the configured client identifier as a member of the verified audience claim. + +## Verification contract + +Regression tests must prove that: + +- varying invalid bearer tokens from one server-observed HTTP peer exhaust one aggregate budget; +- changing `Forwarded` or `X-Forwarded-For` does not create a new application-level peer identity; +- independent trusted peer scopes retain independent budgets; +- a valid token cannot reset the coarse peer failure budget; +- the direct non-HTTP authentication entry point retains its exact-token-only contract; and +- failure-bucket memory remains bounded and time-limited. + +## References + +National Institute of Standards and Technology. (2025). *Digital identity guidelines: Authentication and authenticator management (NIST Special Publication 800-63B-4).* U.S. Department of Commerce. https://doi.org/10.6028/NIST.SP.800-63B-4 + +Jones, M., Bradley, J., & Sakimura, N. (2015). *JSON Web Token (JWT)* (RFC 7519). Internet Engineering Task Force. https://doi.org/10.17487/RFC7519 + +OpenID Foundation. (2014). *OpenID Connect Core 1.0 incorporating errata set 1*. https://openid.net/specs/openid-connect-core-1_0.html From 8dc2431e26271507601f102a525e2b81edb5d24b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 17:00:34 +0900 Subject: [PATCH 07/10] docs(auth): align OIDC and throttle trust boundaries --- docs/operations/auth-key-management.md | 42 ++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/docs/operations/auth-key-management.md b/docs/operations/auth-key-management.md index 418eabe98..f76c90e91 100644 --- a/docs/operations/auth-key-management.md +++ b/docs/operations/auth-key-management.md @@ -29,6 +29,17 @@ - Endpoint tests that need fixture identity use explicit FastAPI dependency overrides in `backend/tests/conftest.py`; those test overrides are not the production auth path. +- Failed HTTP bearer verification is bounded twice: an exact-token SHA-256 + bucket and a coarser SHA-256 bucket derived only from the server-observed ASGI + `request.client.host`. Application code ignores `Forwarded` and + `X-Forwarded-For` when selecting this abuse-control scope. The peer budget is + intentionally looser than the exact-token budget because one reverse proxy or + NAT address can represent many legitimate users; both key families share the + existing bounded-capacity, expiring in-memory store. A successful bearer + verification clears only its exact-token bucket and does not reset the coarse + peer budget. Direct non-HTTP `build_auth_context()` calls retain only the + exact-token budget. See `docs/doctoring/http-session-throttling.md` for the + threat, proxy/NAT, reset, bounded-memory, and standards rationale. - `backend/db/models.py` stores OAuth/OpenAI secret fields through an `EncryptedString` type backed by Fernet. - `backend/db/models.py` no longer contains a fallback Fernet key or SHA256 @@ -121,9 +132,16 @@ private-address resolution and DNS-rebinding bypasses. Development HTTP is limited to exact `localhost`, `127.0.0.1`, or `::1` loopback endpoints. - Browser-side OIDC support does not mint local roles. The IdP token must still - satisfy the backend's signed claim contract: verified issuer, configured - audience, subject, `iat` and `exp` NumericDate values, explicit non-platform - role, organization, groups, workspace, and no unsupported critical headers. + satisfy the backend's signed claim contract: verified issuer equality, a + verified audience claim containing the configured OIDC client ID (including + multi-valued audiences), subject, `iat` and `exp` NumericDate values, explicit + non-platform role, organization, groups, workspace, and no unsupported + critical headers. OpenID Connect Core 1.0 requires exact issuer validation and + requires the relying party's client identifier to be present in `aud`; RFC + 7519 defines `iat` and `exp` as NumericDate claims and the registered JWT claim + semantics. The formal OIDC analysis by Fett, Küsters, and Schmitz (2017) + demonstrates why relying parties must validate issuer/audience and protocol + bindings rather than treating token fields in isolation as sufficient trust. - For a Keyverse deployment, configure the exact Keyverse issuer and JWKS URL, the reviewed `naruon-web` audience, and the operator-owned OIDC host allowlist together. The verified `org`, `workspace`, and `role` claims are inputs to @@ -140,6 +158,24 @@ organization, group/workspace, role, delegation, expiry, and provider/source ownership claims are required before production multi-user access is claimed. +## References + +Fett, D., Küsters, R., & Schmitz, G. (2017). The web SSO standard OpenID Connect: +In-depth formal security analysis and security guidelines. *2017 IEEE 30th +Computer Security Foundations Symposium (CSF)*, 189–202. +https://doi.org/10.1109/CSF.2017.20 + +Jones, M., Bradley, J., & Sakimura, N. (2015). *JSON Web Token (JWT)* (RFC +7519). Internet Engineering Task Force. https://doi.org/10.17487/RFC7519 + +National Institute of Standards and Technology. (2025). *Digital identity +guidelines: Authentication and authenticator management (NIST Special +Publication 800-63B-4).* U.S. Department of Commerce. +https://doi.org/10.6028/NIST.SP.800-63B-4 + +OpenID Foundation. (2014). *OpenID Connect Core 1.0 incorporating errata set 1*. +https://openid.net/specs/openid-connect-core-1_0.html + ## 다음 결정 - Compare Keycloak and Casdoor on OIDC support, operational complexity, admin UX, From a7bdaadb32eecdf55c8ecf3b9def09d673e20b72 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 03:51:18 +0900 Subject: [PATCH 08/10] docs(auth): describe HMAC and OIDC verification modes --- docs/operations/auth-key-management.md | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/docs/operations/auth-key-management.md b/docs/operations/auth-key-management.md index f76c90e91..79eac1535 100644 --- a/docs/operations/auth-key-management.md +++ b/docs/operations/auth-key-management.md @@ -5,15 +5,19 @@ - `backend/api/auth.py` no longer accepts public `X-User-*`, `X-Organization-*`, `X-Group-*`, or `X-Dev-Auth-Token` headers as runtime authentication material. -- Runtime authentication accepts only `Authorization: Bearer` compact session - envelopes whose protected header pins `alg=HS256` and whose `header.payload` - signing input is signed with HMAC-SHA256 by the configured - `AUTH_SESSION_HMAC_SECRET`. The secret must be explicitly configured, - high-entropy generated material, and at least 32 bytes. Settings fail at - startup in every runtime mode when this secret is missing, too short, or an - obvious repeated placeholder or known public fixture value; runtime - verification still fails closed with `401 Authentication required` when an - already-loaded configured value becomes absent, weak, or public. +- Runtime authentication accepts `Authorization: Bearer` compact sessions through + two fail-closed verification modes. The internal HMAC session envelope pins its + protected header to `alg=HS256` and signs the `header.payload` input with + HMAC-SHA256 by the configured `AUTH_SESSION_HMAC_SECRET`. When OIDC is + configured, the same bearer boundary also accepts OIDC sessions only after the + configured issuer, client audience membership, JOSE header constraints, JWKS + signature, `iat`, `exp`, and required tenant/role claims are verified as + described below. The HMAC secret must be explicitly configured, high-entropy + generated material, and at least 32 bytes. Settings fail at startup in every + runtime mode when this secret is missing, too short, or an obvious repeated + placeholder or known public fixture value; runtime HMAC verification still + fails closed with `401 Authentication required` when an already-loaded + configured value becomes absent, weak, or public. - The signed session payload is versioned and must include `iss=naruon-control-plane`, `aud=naruon-api`, `sub`, explicit `role`, `workspace`, `exp`, and organization/group scope claims. Tampered, expired, From ffb2363192eb6004dea6154faea3df34bc767172 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 05:50:59 +0900 Subject: [PATCH 09/10] test(security): reject unvalidated IMAP fetch destinations --- backend/tests/test_imap_worker.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/backend/tests/test_imap_worker.py b/backend/tests/test_imap_worker.py index d2798719a..410f487e6 100644 --- a/backend/tests/test_imap_worker.py +++ b/backend/tests/test_imap_worker.py @@ -27,6 +27,29 @@ def fail_connect(*args, **kwargs): assert connection_attempts == [] +@pytest.mark.asyncio +async def test_imap_worker_fetch_rejects_explicit_disallowed_destination(monkeypatch): + """Explicit fetch overrides must pass mail egress policy before networking.""" + worker = ImapSyncWorker() + config = TenantConfig( + user_id="testuser", + imap_server="imap.example.com", + imap_port=993, + ) + connection_attempts = [] + + def fail_connect(*args, **kwargs): + connection_attempts.append((args, kwargs)) + raise AssertionError("IMAP connection must not open before policy validation") + + monkeypatch.setattr("services.imap_worker.aioimaplib.IMAP4_SSL", fail_connect) + + with pytest.raises(ValueError, match="IMAP server is not allowed"): + await worker._fetch_messages(config, "127.0.0.1", 993) + + assert connection_attempts == [] + + @pytest.mark.asyncio async def test_imap_worker_sync_tenant_raises_when_connection_fails(monkeypatch): worker = ImapSyncWorker() From 2790a7edff5f5ed29a6a7aedcd398bc0d5ef7c06 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 05:51:44 +0900 Subject: [PATCH 10/10] fix(security): validate IMAP fetch destinations --- backend/services/imap_worker.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/services/imap_worker.py b/backend/services/imap_worker.py index d618f1d3c..6a396ce29 100644 --- a/backend/services/imap_worker.py +++ b/backend/services/imap_worker.py @@ -251,6 +251,8 @@ async def _fetch_messages( ) -> list[bytes]: if imap_server is None or imap_port is None: imap_server, imap_port = self._validated_destination(config) + else: + imap_server, imap_port = validate_imap_destination(imap_server, imap_port) import ssl ssl_context = ssl.create_default_context() imap_client = aioimaplib.IMAP4_SSL(