diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 9c9d083b..44e5bdc7 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -65,3 +65,8 @@ **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-18 - [Sentinel: Unhandled FastAPI Exception via HMAC] +**취약점:** `hmac.compare_digest`를 사용하여 비 ASCII 문자열(예: 악성 `x-api-key` 헤더)을 비교할 때 발생하는 `TypeError`로 인한 처리되지 않은 예외(DoS 취약점). +**학습:** `hmac.compare_digest()`는 비 ASCII 문자가 포함된 문자열을 비교할 때 `TypeError`를 발생시킵니다. 클라이언트에서 거부하지 않는 원시 바이트 헤더가 미들웨어에 도달하면 이 예외가 서버 에러(500)를 유발하여 애플리케이션의 가용성에 영향을 미칠 수 있습니다. +**예방:** `hmac.compare_digest`의 두 인수를 비교하기 전에 항상 명시적으로 바이트 형식(예: `.encode('utf-8')`)으로 인코딩하여 처리되지 않은 예외 취약점을 방지해야 합니다. diff --git a/saas_web.py b/saas_web.py index 63265e94..7a1b8900 100644 --- a/saas_web.py +++ b/saas_web.py @@ -113,8 +113,9 @@ async def require_api_key(request: Request, call_next): configured_keys = get_configured_api_keys() if configured_keys and not (request.method == "GET" and request.url.path == "/"): provided_key = request.headers.get("x-api-key", "") + provided_key_bytes = provided_key.encode('utf-8') if not any( - hmac.compare_digest(provided_key, key) for key in configured_keys + hmac.compare_digest(provided_key_bytes, 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..23e61705 100644 --- a/tests/test_saas_web.py +++ b/tests/test_saas_web.py @@ -675,7 +675,7 @@ def test_get_ui_includes_batch_upload_form(self): @unittest.skipUnless( _HAS_FASTAPI, "fastapi not installed (optional integration dependency)" ) -class TestApiKeyAuth(unittest.TestCase): +class TestApiKeyAuth(unittest.IsolatedAsyncioTestCase): """Tests for the opt-in CODEC_CARVER_API_KEYS authentication middleware.""" def _post_shrink(self, headers=None): @@ -707,6 +707,16 @@ def test_missing_header_rejected_when_keys_configured(self): self.assertEqual(response.json(), {"error": "Invalid or missing API key"}) self.assertNotIn("secret-key", response.text) + async def test_non_ascii_header_does_not_crash(self): + import starlette.requests + async def mock_call_next(request): + return "SUCCESS" + scope = {'type': 'http', 'method': 'GET', 'path': '/shrink', 'headers': [(b'x-api-key', '안녕'.encode('utf-8'))]} + request = starlette.requests.Request(scope) + with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": "secret-key"}): + response = await saas_web.require_api_key(request, mock_call_next) + self.assertEqual(response.status_code, 401) + def test_wrong_key_rejected(self): with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": "secret-key"}): response = self._post_shrink(headers={"X-API-Key": "wrong-key"})