From 3bfba9a382b995e67fd19d8c489a727eb49a1413 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:25:22 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITICAL]?= =?UTF-8?q?=20Fix=20API=20Key=20DoS=20vulnerability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hmac.compare_digest` 함수에 전달하기 전에 헤더와 구성된 키 모두 `utf-8` 바이트로 인코딩(`encode("utf-8")`)하여 non-ASCII 문자 입력 시 발생하는 `TypeError`를 방지하고 500 에러로 인한 서비스 거부(DoS) 취약점을 수정했습니다. --- .jules/sentinel.md | 4 ++++ saas_web.py | 3 ++- tests/test_saas_web.py | 22 ++++++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 9c9d083b..36581e71 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. +## 2024-08-28 - Fix DoS via hmac.compare_digest on non-ASCII characters +**Vulnerability:** Sending non-ASCII characters (like emojis) in the `x-api-key` header caused `hmac.compare_digest` to raise a `TypeError: comparing strings with non-ASCII characters is not supported`, leading to a 500 Internal Server Error. +**Learning:** `hmac.compare_digest` cannot handle Unicode strings containing non-ASCII characters natively. When processing user-controlled strings (like HTTP headers), they must be encoded to bytes first. Using unencoded strings creates a Denial of Service (DoS) attack vector where an attacker can repeatedly crash the application state. +**Prevention:** Always encode user-controlled strings to bytes (`.encode("utf-8")`) before passing them to `hmac.compare_digest`. Ensure this encoding happens efficiently (e.g., outside of loops for invariant values). diff --git a/saas_web.py b/saas_web.py index 63265e94..92585502 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..4a42f3fa 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_does_not_crash(self): + import asyncio + from starlette.requests import Request + import saas_web + + async def run_test(): + async def mock_call_next(request): + return "SUCCESS" + + with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": "secret-key"}): + scope = { + "type": "http", + "method": "POST", + "path": "/shrink", + "headers": [(b"x-api-key", "secret✨".encode("utf-8"))] + } + req = Request(scope) + response = await saas_web.require_api_key(req, mock_call_next) + self.assertEqual(response.status_code, 401) + + asyncio.run(run_test()) + def test_shrink_media_rejects_missing_filename(self): response = saas_web.shrink_media( BackgroundTasks(),