Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,8 @@
**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.

## 2024-05-24 - JWT Header Verification Missing `crit` Processing
**Vulnerability:** OIDC 토큰 검증 μ‹œ, JWT ν—€λ”μ˜ `crit` (critical) ν•„λ“œκ°€ ν™•μΈλ˜μ§€ μ•Šκ³  λ¬΄μ‹œλ˜κ³  μžˆμ—ˆμŠ΅λ‹ˆλ‹€.
**Learning:** RFC 7515에 λ”°λ₯΄λ©΄ `crit` ν—€λ”λŠ” λ°°μ—΄ ν˜•νƒœμ—¬μ•Ό ν•˜λ©°, λͺ…μ‹œλœ ν™•μž₯ νŒŒλΌλ―Έν„°λ“€μ„ μ„œλ²„κ°€ μ΄ν•΄ν•˜κ³  μ²˜λ¦¬ν•  수 μžˆμ–΄μ•Όλ§Œ 토큰을 μˆ˜μš©ν•΄μ•Ό ν•©λ‹ˆλ‹€. 이λ₯Ό ν™•μΈν•˜μ§€ μ•ŠμœΌλ©΄ STRIX λ³΄μ•ˆ μš”κ±΄μ„ 톡과할 수 μ—†κ³  μ•Œ 수 μ—†λŠ” ν™•μž₯ κΈ°λŠ₯이 ν¬ν•¨λœ 토큰이 μ•…μš©λ  μœ„ν—˜μ΄ μžˆμŠ΅λ‹ˆλ‹€. 이 ν”„λ‘œμ νŠΈμ˜ 경우 μ»€μŠ€ν…€ ν™•μž₯을 μ§€μ›ν•˜μ§€ μ•ŠμœΌλ―€λ‘œ `crit` 헀더가 ν¬ν•¨λœ 토큰은 κ±°λΆ€λ˜μ–΄μ•Ό ν•©λ‹ˆλ‹€.
**Prevention:** `_validate_jwt_header` ν•¨μˆ˜μ— `crit` νŒŒλΌλ―Έν„°κ°€ μžˆλŠ”μ§€ κ²€μ‚¬ν•˜λŠ” λ‘œμ§μ„ μΆ”κ°€ν•˜κ³ , λ§Œμ•½ μ‘΄μž¬ν•  경우 λ¬Έμžμ—΄μ˜ λ°°μ—΄(길이 10 μ΄ν•˜)인지 확인 ν›„ μ§€μ›λ˜μ§€ μ•ŠλŠ” ν™•μž₯ κΈ°λŠ₯μ΄λ―€λ‘œ μ˜ˆμ™Έλ₯Ό λ°œμƒμ‹œν‚€λ„λ‘ μˆ˜μ •ν–ˆμŠ΅λ‹ˆλ‹€.
20 changes: 16 additions & 4 deletions backend/app/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import httpx
from fastapi import Depends, HTTPException, Request
from jose import jwt
from sqlalchemy import select, delete
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession

from app.db import get_session
Expand Down Expand Up @@ -185,6 +185,18 @@ def _validate_jwt_header(header: dict[str, Any]) -> str:
if content_type is not None:
raise HTTPException(status_code=401, detail="unsupported token content type")

crit = header.get("crit")
if crit is not None:
if not isinstance(crit, list) or len(crit) > 10:
raise HTTPException(status_code=401, detail="invalid crit header")
for item in crit:
if not isinstance(item, str):
raise HTTPException(status_code=401, detail="invalid crit header")
# We do not support any critical extensions
raise HTTPException(
status_code=401, detail="unsupported critical extension"
)

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")
Expand All @@ -194,8 +206,8 @@ def _validate_jwt_header(header: dict[str, Any]) -> str:
async def revoke_token_jti(jwt_id: str, expires_at: dt.datetime) -> None:
"""Record a JWT ID as revoked until its natural expiry."""

from app.models import RevokedToken
from app.db import SessionLocal
from app.models import RevokedToken

if not jwt_id:
return
Expand All @@ -213,8 +225,8 @@ async def revoke_token_jti(jwt_id: str, expires_at: dt.datetime) -> None:
async def is_token_jti_revoked(jwt_id: str) -> bool:
"""Return whether the JWT ID is currently revoked."""

from app.models import RevokedToken
from app.db import SessionLocal
from app.models import RevokedToken

current = dt.datetime.now(dt.timezone.utc)
async with SessionLocal() as session:
Expand Down Expand Up @@ -464,7 +476,7 @@ async def get_current_user(
"""
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer " + API_KEY_PREFIX):
return await _user_from_api_key(session, auth_header[len("Bearer "):])
return await _user_from_api_key(session, auth_header[len("Bearer ") :])
subject, display_name = await _get_subject_from_request(request)
async with session.begin():
return await _ensure_user(session, subject, display_name)
Expand Down
44 changes: 38 additions & 6 deletions backend/tests/test_auth_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ class FakeAsyncClient:
def __init__(self, **kwargs: object) -> None:
observed.update(kwargs)

async def __aenter__(self) -> "FakeAsyncClient":
async def __aenter__(self) -> FakeAsyncClient:
return self

async def __aexit__(self, *_args: object) -> None:
Expand Down Expand Up @@ -109,7 +109,7 @@ class FakeAsyncClient:
def __init__(self, **_kwargs: object) -> None:
return None

async def __aenter__(self) -> "FakeAsyncClient":
async def __aenter__(self) -> FakeAsyncClient:
return self

async def __aexit__(self, *_args: object) -> None:
Expand Down Expand Up @@ -147,7 +147,7 @@ class FakeAsyncClient:
def __init__(self, **kwargs: object) -> None:
observed.update(kwargs)

async def __aenter__(self) -> "FakeAsyncClient":
async def __aenter__(self) -> FakeAsyncClient:
return self

async def __aexit__(self, *_args: object) -> None:
Expand Down Expand Up @@ -467,6 +467,33 @@ async def mock_revoke(jti, ext):
assert exc_info.value.detail == "token revoked"


@pytest.mark.asyncio
async def test_oidc_rejects_invalid_crit_header() -> None:
# crit is not a list
with pytest.raises(HTTPException) as excinfo:
auth._validate_jwt_header({"alg": "RS256", "crit": "b64"})
assert excinfo.value.status_code == 401
assert excinfo.value.detail == "invalid crit header"

# crit list is too long
with pytest.raises(HTTPException) as excinfo:
auth._validate_jwt_header({"alg": "RS256", "crit": ["ext"] * 11})
assert excinfo.value.status_code == 401
assert excinfo.value.detail == "invalid crit header"

# crit item is not a string
with pytest.raises(HTTPException) as excinfo:
auth._validate_jwt_header({"alg": "RS256", "crit": [123]})
assert excinfo.value.status_code == 401
assert excinfo.value.detail == "invalid crit header"

# crit contains unsupported extension
with pytest.raises(HTTPException) as excinfo:
auth._validate_jwt_header({"alg": "RS256", "crit": ["b64"]})
assert excinfo.value.status_code == 401
assert excinfo.value.detail == "unsupported critical extension"


@pytest.mark.asyncio
async def test_auth_fails_closed_without_oidc(
monkeypatch: pytest.MonkeyPatch,
Expand Down Expand Up @@ -594,6 +621,7 @@ async def mock_is_token_revoked2(jti):
assert exc_info.value.status_code == 401
assert exc_info.value.detail == "token verification failed"


@pytest.mark.asyncio
async def test_oidc_rejects_algorithm_key_type_mismatch(
monkeypatch: pytest.MonkeyPatch,
Expand All @@ -617,7 +645,9 @@ async def mock_is_token_revoked2(jti):
monkeypatch.setattr(auth, "is_token_jti_revoked", mock_is_token_revoked2)

def fail_decode(*_: object, **__: object) -> dict:
raise AssertionError("jwt.decode must not run for mismatched algorithm/key type")
raise AssertionError(
"jwt.decode must not run for mismatched algorithm/key type"
)

monkeypatch.setattr(auth.jwt, "decode", fail_decode)

Expand All @@ -626,6 +656,8 @@ def fail_decode(*_: object, **__: object) -> dict:

assert exc_info.value.status_code == 401
assert exc_info.value.detail == "algorithm/key type mismatch"


@pytest.mark.asyncio
async def test_oidc_jwks_refresh_rate_limiting(
monkeypatch: pytest.MonkeyPatch,
Expand All @@ -636,7 +668,7 @@ class FakeAsyncClient:
def __init__(self, **kwargs: object) -> None:
pass

async def __aenter__(self) -> "FakeAsyncClient":
async def __aenter__(self) -> FakeAsyncClient:
return self

async def __aexit__(self, *_args: object) -> None:
Expand Down Expand Up @@ -684,7 +716,7 @@ class FakeAsyncClient:
def __init__(self, **kwargs: object) -> None:
pass

async def __aenter__(self) -> "FakeAsyncClient":
async def __aenter__(self) -> FakeAsyncClient:
return self

async def __aexit__(self, *_args: object) -> None:
Expand Down
Loading