diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 9c9d083b..9040d758 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. + +## 2024-05-31 - [Sentinel: Unhandled UnicodeError in hmac.compare_digest] +**취약점:** `hmac.compare_digest()` 사용 시 비 ASCII 문자가 입력으로 들어올 때 발생하는 `TypeError`를 통한 500 서버 에러 (DoS 취약점). +**학습:** Python의 `hmac.compare_digest()`는 비 ASCII 문자가 포함된 문자열(`str`) 인스턴스를 비교할 때 예외(`TypeError`)를 발생시킵니다. 웹 프레임워크 미들웨어에서 인증 헤더와 같이 사용자 제어가 가능한 입력을 이 함수로 직접 비교하면 악의적인 사용자가 비 ASCII 문자(예: 이모지)를 전송하여 서비스 거부 상태를 유발할 수 있습니다. +**예방:** `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..c3c8468f 100644 --- a/tests/test_saas_web.py +++ b/tests/test_saas_web.py @@ -32,6 +32,36 @@ _HAS_FASTAPI, "fastapi not installed (optional integration dependency)" ) class TestSaasWeb(unittest.TestCase): + def test_require_api_key_non_ascii_header(self): + from starlette.requests import Request + from starlette.responses import JSONResponse + import asyncio + + scope = { + 'type': 'http', + 'method': 'POST', + 'path': '/jobs', + 'headers': [(b'x-api-key', b'test\xf0\x9f\x98\x80')] # 'test😀' in utf-8 + } + request = Request(scope) + + async def mock_call_next(req): + return JSONResponse(status_code=200, content={"status": "ok"}) + + import os + import saas_web + + original_keys = os.environ.get("CODEC_CARVER_API_KEYS") + os.environ["CODEC_CARVER_API_KEYS"] = "testkey" + try: + response = asyncio.run(saas_web.require_api_key(request, mock_call_next)) + self.assertEqual(response.status_code, 401) + finally: + if original_keys is not None: + os.environ["CODEC_CARVER_API_KEYS"] = original_keys + else: + del os.environ["CODEC_CARVER_API_KEYS"] + def test_get_ui(self): response = client.get("/") self.assertEqual(response.status_code, 200)