Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
31 changes: 16 additions & 15 deletions backend/app/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand All @@ -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()


Expand Down Expand Up @@ -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()
Expand All @@ -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(
Expand All @@ -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:
Expand All @@ -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,
Expand Down
18 changes: 9 additions & 9 deletions backend/tests/test_auth_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
),
],
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
Loading