diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 1c145b3a0..036556fa7 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -2,3 +2,7 @@ **Vulnerability:** User-provided string fields (like project and connection names) lacked strict validation against control characters, only relying on length constraints. **Learning:** This could potentially lead to Log Injection (CRLF injection), Null Byte Injection, or terminal escape injection if these strings are subsequently logged or rendered directly. **Prevention:** Use explicit regex validation `pattern=r'^[^\x00-\x1F\x7F]+$'` on Pydantic string fields to strictly reject control characters. +## 2025-02-18 - Prevent Information Leakage During JWT Authentication +**Vulnerability:** JWT validation errors were exposing specific failure reasons (e.g., "unknown signing key", "token revoked", "algorithm/key type mismatch") in HTTP 401 response details. +**Learning:** Returning overly verbose authentication errors leaks internal state and validation logic, which attackers can use to probe or bypass the authentication mechanism. +**Prevention:** Always use generic error messages (e.g., "invalid token") for authentication failures, and ensure the test suite is configured to expect these generic responses to enforce this pattern. diff --git a/backend/app/auth.py b/backend/app/auth.py index d328aa3a7..f34703174 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -166,7 +166,7 @@ def _jwt_expiry(claims: dict[str, Any]) -> dt.datetime: exp = claims.get("exp") if not isinstance(exp, int | float): - raise HTTPException(status_code=401, detail="token missing exp") + raise HTTPException(status_code=401, detail="invalid token") return dt.datetime.fromtimestamp(float(exp), tz=dt.timezone.utc) @@ -179,15 +179,15 @@ def _validate_jwt_header(header: dict[str, Any]) -> str: not isinstance(token_type, str) or token_type.strip().lower() not in OIDC_ALLOWED_TOKEN_TYPES ): - raise HTTPException(status_code=401, detail="unsupported token type") + raise HTTPException(status_code=401, detail="invalid token") content_type = header.get("cty") if content_type is not None: - raise HTTPException(status_code=401, detail="unsupported token content type") + raise HTTPException(status_code=401, detail="invalid token") header_alg_raw = header.get("alg") if not isinstance(header_alg_raw, str) or not header_alg_raw: - raise HTTPException(status_code=401, detail="token missing alg") + raise HTTPException(status_code=401, detail="invalid token") return header_alg_raw.upper() @@ -240,13 +240,13 @@ async def _decode_verified_oidc_token(token: str) -> dict[str, Any]: try: header = cast(dict[str, Any], jwt.get_unverified_header(token)) except Exception: # noqa: BLE001 - raise HTTPException(status_code=401, detail="invalid token header") + raise HTTPException(status_code=401, detail="invalid token") header_alg = _validate_jwt_header(header) if header_alg not in OIDC_ALLOWED_ALGORITHMS: raise HTTPException( status_code=401, - detail="unsupported token algorithm", + detail="invalid token", ) jwks = await _get_jwks() @@ -255,20 +255,20 @@ async def _decode_verified_oidc_token(token: str) -> dict[str, Any]: jwks = await _get_jwks(force_refresh=True) jwk = _pick_jwk(jwks, header.get("kid")) if jwk is None: - raise HTTPException(status_code=401, detail="unknown signing key") + raise HTTPException(status_code=401, detail="invalid token") kty = jwk.get("kty") if not isinstance(kty, str): - raise HTTPException(status_code=401, detail="algorithm/key type mismatch") + raise HTTPException(status_code=401, detail="invalid token") jwk_kty = kty.upper() if jwk_kty == "RSA": if not (header_alg.startswith("RS") or header_alg.startswith("PS")): - raise HTTPException(status_code=401, detail="algorithm/key type mismatch") + raise HTTPException(status_code=401, detail="invalid token") elif jwk_kty == "EC": if not header_alg.startswith("ES"): - raise HTTPException(status_code=401, detail="algorithm/key type mismatch") + raise HTTPException(status_code=401, detail="invalid token") else: - raise HTTPException(status_code=401, detail="algorithm/key type mismatch") + raise HTTPException(status_code=401, detail="invalid token") try: claims = jwt.decode( @@ -288,12 +288,13 @@ async def _decode_verified_oidc_token(token: str) -> dict[str, Any]: ) except Exception as err: raise HTTPException( - status_code=401, detail="token verification failed" + status_code=401, detail="invalid token" ) from err return cast(dict[str, Any], claims) +# Security Note: Prevent information leakage during authentication by using generic 401 exceptions. async def _verified_token_from_claims( claims: dict[str, Any], verify_revocation: bool = True ) -> VerifiedToken: @@ -303,13 +304,13 @@ async def _verified_token_from_claims( jwt_id = claims.get("jti") name = claims.get("name") or claims.get("preferred_username") if not isinstance(sub, str): - raise HTTPException(status_code=401, detail="token missing sub") + raise HTTPException(status_code=401, detail="invalid token") if not isinstance(jwt_id, str) or not jwt_id.strip(): - raise HTTPException(status_code=401, detail="token missing jti") + raise HTTPException(status_code=401, detail="invalid token") expires_at = _jwt_expiry(claims) if verify_revocation and await is_token_jti_revoked(jwt_id): - raise HTTPException(status_code=401, detail="token revoked") + raise HTTPException(status_code=401, detail="invalid token") return VerifiedToken( subject=sub, diff --git a/backend/tests/test_auth_security.py b/backend/tests/test_auth_security.py index 9f93eb4f1..24485d6bb 100644 --- a/backend/tests/test_auth_security.py +++ b/backend/tests/test_auth_security.py @@ -207,7 +207,7 @@ def fail_decode(*_: object, **__: object) -> dict: ) assert exc_info.value.status_code == 401 - assert exc_info.value.detail == "unsupported token algorithm" + assert exc_info.value.detail == "invalid token" @pytest.mark.asyncio @@ -244,7 +244,7 @@ def fail_decode(*_: object, **__: object) -> dict: await auth._decode_verified_oidc_token("ey...fake...") assert exc_info.value.status_code == 401 - assert exc_info.value.detail == "algorithm/key type mismatch" + assert exc_info.value.detail == "invalid token" @pytest.mark.asyncio @@ -307,11 +307,11 @@ async def mock_is_token_revoked(jti): [ ( {"kid": "key-1", "alg": "RS256", "typ": "nested+jwt"}, - "unsupported token type", + "invalid token", ), ( {"kid": "key-1", "alg": "RS256", "cty": "JWT"}, - "unsupported token content type", + "invalid token", ), ], ) @@ -417,7 +417,7 @@ async def mock_is_token_revoked2(jti): ) assert exc_info.value.status_code == 401 - assert exc_info.value.detail == "token missing jti" + assert exc_info.value.detail == "invalid token" @pytest.mark.asyncio @@ -464,7 +464,7 @@ async def mock_revoke(jti, ext): ) assert exc_info.value.status_code == 401 - assert exc_info.value.detail == "token revoked" + assert exc_info.value.detail == "invalid token" @pytest.mark.asyncio @@ -560,7 +560,7 @@ def mock_get_unverified_header(token): await auth._decode_verified_oidc_token("invalid_token") assert excinfo.value.status_code == 401 - assert excinfo.value.detail == "invalid token header" + assert excinfo.value.detail == "invalid token" @pytest.mark.asyncio @@ -592,7 +592,7 @@ async def mock_is_token_revoked2(jti): await auth._decode_verified_oidc_token("Bearer token") assert exc_info.value.status_code == 401 - assert exc_info.value.detail == "token verification failed" + assert exc_info.value.detail == "invalid token" @pytest.mark.asyncio async def test_oidc_rejects_algorithm_key_type_mismatch( @@ -625,7 +625,7 @@ def fail_decode(*_: object, **__: object) -> dict: await auth._decode_verified_oidc_token("ey...") assert exc_info.value.status_code == 401 - assert exc_info.value.detail == "algorithm/key type mismatch" + assert exc_info.value.detail == "invalid token" @pytest.mark.asyncio async def test_oidc_jwks_refresh_rate_limiting( monkeypatch: pytest.MonkeyPatch,