diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 9c9d083b..1b8e0b34 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -65,3 +65,7 @@ **Vulnerability:** Path traversal in `media_shrinker.py` via unresolved `..` segments or symlink escapes before deriving conversion output paths. **Learning:** `Path.relative_to()` is only a lexical containment check unless both the source and root have first been resolved into canonical absolute paths. Relative paths and symlinks can otherwise bypass root-boundary assumptions. **Prevention:** Resolve both source and root once, reject sources outside the resolved root with a sanitized `MediaShrinkerError`, and derive `rel_source` from the resolved paths before planning outputs. +## 2026-08-04 - hmac.compare_digest 비-ASCII 문자열 처리 오류 수정 +**취약점:** FastAPI 미들웨어에서 `hmac.compare_digest` 사용 시, 악의적인 사용자가 API 키 헤더에 비-ASCII 문자를 포함하면 `TypeError`가 발생하여 500 서버 에러(DoS)가 유발될 수 있음. +**학습:** 파이썬의 `hmac.compare_digest()`는 비-ASCII 문자가 포함된 문자열을 직접 비교할 수 없음. +**예방:** 항상 두 인자를 명시적으로 바이트(`utf-8`)로 인코딩한 후 비교해야 안전하게 검증할 수 있음. diff --git a/saas_web.py b/saas_web.py index 63265e94..071b1419 100644 --- a/saas_web.py +++ b/saas_web.py @@ -114,7 +114,7 @@ async def require_api_key(request: Request, call_next): if configured_keys and not (request.method == "GET" and request.url.path == "/"): provided_key = request.headers.get("x-api-key", "") if not any( - hmac.compare_digest(provided_key, key) for key in configured_keys + hmac.compare_digest(provided_key.encode("utf-8"), key.encode("utf-8")) for key in configured_keys ): return JSONResponse( status_code=401, diff --git a/tests/test_saas_web.py b/tests/test_saas_web.py index 3b57e033..a413455c 100644 --- a/tests/test_saas_web.py +++ b/tests/test_saas_web.py @@ -186,6 +186,28 @@ def test_shrink_media_rejects_nonpositive_target_bytes(self): {"error": "Invalid target_bytes value. Must be greater than 0."}, ) + def test_non_ascii_key_rejected_safely(self): + import asyncio + import starlette.requests + from starlette.responses import JSONResponse + + async def call_next(request): + return JSONResponse({"status": "ok"}) + + scope = { + "type": "http", + "method": "POST", + "path": "/shrink", + "headers": [(b"x-api-key", "invalíd".encode("utf-8"))], + } + request = starlette.requests.Request(scope) + + with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": "secret-key"}): + response = asyncio.run(saas_web.require_api_key(request, call_next)) + + self.assertEqual(response.status_code, 401) + self.assertEqual(json.loads(response.body), {"error": "Invalid or missing API key"}) + def test_shrink_media_rejects_missing_filename(self): response = saas_web.shrink_media( BackgroundTasks(),