From 5a9e3d788e3fdbd1fcad68ec2b5805810a184c20 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:30:24 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITIC?= =?UTF-8?q?AL]=20hmac.compare=5Fdigest=EC=9D=98=20TypeError=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B8=ED=95=9C=20API=20=ED=82=A4=20DoS=20=EC=B7=A8=EC=95=BD?= =?UTF-8?q?=EC=A0=90=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .jules/sentinel.md | 4 ++++ saas_web.py | 3 ++- tests/test_saas_web.py | 8 ++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 9c9d083b..0e21973f 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 Authentication DoS via TypeError +**Vulnerability:** A Denial of Service (DoS) vulnerability existed in `require_api_key` where an attacker could crash the server (HTTP 500) by providing a non-ASCII string in the `x-api-key` header, which caused a `TypeError` when compared using `hmac.compare_digest`. +**Learning:** `hmac.compare_digest` in Python expects either ASCII-only strings or bytes. Passing a non-ASCII string directly crashes the function. Input from HTTP headers can contain non-ASCII characters and should be treated as untrusted. +**Prevention:** Always encode user-controlled strings to bytes using `.encode('utf-8')` before using them in cryptographic or constant-time comparison functions like `hmac.compare_digest`. 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..7b2dfba8 100644 --- a/tests/test_saas_web.py +++ b/tests/test_saas_web.py @@ -707,6 +707,14 @@ 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_rejected_gracefully(self): + with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": "secret-key"}): + response = self._post_shrink(headers={"X-API-Key": "non_ascii_キー"}) + + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json(), {"error": "Invalid or missing API key"}) + self.assertNotIn("secret-key", response.text) + 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 935b781b83c73989e7fc5df632abbd0959fdfc80 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:09:20 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITIC?= =?UTF-8?q?AL]=20hmac.compare=5Fdigest=EC=9D=98=20TypeError=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B8=ED=95=9C=20API=20=ED=82=A4=20DoS=20=EC=B7=A8=EC=95=BD?= =?UTF-8?q?=EC=A0=90=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_saas_web.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/tests/test_saas_web.py b/tests/test_saas_web.py index 7b2dfba8..3b333424 100644 --- a/tests/test_saas_web.py +++ b/tests/test_saas_web.py @@ -10,6 +10,7 @@ from types import SimpleNamespace try: + import starlette.requests from fastapi import BackgroundTasks from fastapi.testclient import TestClient from fastapi.responses import Response @@ -675,7 +676,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,13 +708,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_rejected_gracefully(self): + async def test_non_ascii_key_rejected_gracefully(self): + import saas_web with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": "secret-key"}): - response = self._post_shrink(headers={"X-API-Key": "non_ascii_キー"}) + scope = { + "type": "http", + "method": "POST", + "path": "/shrink", + "headers": [(b"x-api-key", "non_ascii_キー".encode("utf-8"))] + } + request = starlette.requests.Request(scope) + + async def mock_call_next(request): + return saas_web.JSONResponse(status_code=200, content={"status": "success"}) + + response = await saas_web.require_api_key(request, mock_call_next) self.assertEqual(response.status_code, 401) - self.assertEqual(response.json(), {"error": "Invalid or missing API key"}) - self.assertNotIn("secret-key", response.text) + import json + body = json.loads(response.body) + self.assertEqual(body, {"error": "Invalid or missing API key"}) def test_wrong_key_rejected(self): with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": "secret-key"}):