From 6b08e08dceba8c99fc8d8f91e9e6e35287cb76b6 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:10:09 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITIC?= =?UTF-8?q?AL]=20Fix=20500=20error=20on=20API=20key=20check=20due=20to=20h?= =?UTF-8?q?mac=20character=20encoding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update `hmac.compare_digest` calls in `saas_web.py` to convert arguments to UTF-8 before comparison. * Add a test `test_non_ascii_key_does_not_crash_but_rejected` in `tests/test_saas_web.py` to prevent regression. --- .jules/sentinel.md | 4 ++++ saas_web.py | 2 +- tests/test_saas_web.py | 20 ++++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 9c9d083b..60612fed 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-05-18 - API Key HMAC Encoding DoS +**Vulnerability:** Unhandled TypeError when processing non-ASCII characters in X-API-Key header during `hmac.compare_digest`, leading to 500 Internal Server Errors (Denial of Service). +**Learning:** Python's `hmac.compare_digest` strictly requires ASCII-only strings or bytes objects. The previous implementation passed HTTP headers directly as strings, which can contain non-ASCII chars if sent maliciously. +**Prevention:** Always encode strings to `utf-8` bytes before passing them to `hmac.compare_digest` to prevent DoS via unhandled exceptions in authentication middleware. 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..1e8c1b7c 100644 --- a/tests/test_saas_web.py +++ b/tests/test_saas_web.py @@ -707,6 +707,26 @@ 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) + def test_non_ascii_key_does_not_crash_but_rejected(self): + with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": "secret-key"}): + import fastapi + import asyncio + import json + import saas_web + scope = { + 'type': 'http', + 'method': 'POST', + 'path': '/api/upload', + 'headers': [(b'x-api-key', b'mykey\xe2\x9c\xa8')] # ✨ character in utf-8 + } + request = fastapi.Request(scope) + async def mock_call_next(req): + return fastapi.responses.JSONResponse(status_code=200, content={"status": "ok"}) + response = asyncio.run(saas_web.require_api_key(request, mock_call_next)) + + self.assertEqual(response.status_code, 401) + self.assertEqual(json.loads(response.body), {"error": "Invalid or missing API key"}) + 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"}) From 5022b72e66ca4a74080ada6c56253953460c81a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 9 Sep 2026 06:19:38 +0900 Subject: [PATCH 2/2] chore(auth): converge duplicate non-ASCII key repair --- .jules/sentinel.md | 4 ---- saas_web.py | 2 +- tests/test_saas_web.py | 20 -------------------- 3 files changed, 1 insertion(+), 25 deletions(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 60612fed..9c9d083b 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -65,7 +65,3 @@ **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-18 - API Key HMAC Encoding DoS -**Vulnerability:** Unhandled TypeError when processing non-ASCII characters in X-API-Key header during `hmac.compare_digest`, leading to 500 Internal Server Errors (Denial of Service). -**Learning:** Python's `hmac.compare_digest` strictly requires ASCII-only strings or bytes objects. The previous implementation passed HTTP headers directly as strings, which can contain non-ASCII chars if sent maliciously. -**Prevention:** Always encode strings to `utf-8` bytes before passing them to `hmac.compare_digest` to prevent DoS via unhandled exceptions in authentication middleware. diff --git a/saas_web.py b/saas_web.py index 071b1419..63265e94 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.encode("utf-8"), key.encode("utf-8")) for key in configured_keys + hmac.compare_digest(provided_key, key) 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 1e8c1b7c..3b57e033 100644 --- a/tests/test_saas_web.py +++ b/tests/test_saas_web.py @@ -707,26 +707,6 @@ 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) - def test_non_ascii_key_does_not_crash_but_rejected(self): - with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": "secret-key"}): - import fastapi - import asyncio - import json - import saas_web - scope = { - 'type': 'http', - 'method': 'POST', - 'path': '/api/upload', - 'headers': [(b'x-api-key', b'mykey\xe2\x9c\xa8')] # ✨ character in utf-8 - } - request = fastapi.Request(scope) - async def mock_call_next(req): - return fastapi.responses.JSONResponse(status_code=200, content={"status": "ok"}) - response = asyncio.run(saas_web.require_api_key(request, mock_call_next)) - - self.assertEqual(response.status_code, 401) - self.assertEqual(json.loads(response.body), {"error": "Invalid or missing API key"}) - 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"})