From 08ff8306b4bef7249b2e80d697fa6ff0a52320e2 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:13:59 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITIC?= =?UTF-8?q?AL]=20hmac.compare=5Fdigest=EC=9D=98=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EB=90=98=EC=A7=80=20=EC=95=8A=EC=9D=80=20TypeError=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B8=ED=95=9C=20DoS=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 | 5 +++++ saas_web.py | 2 +- 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..fc088f2c 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-07-15 - [Sentinel: DoS via HMAC Compare Digest TypeError] +**Vulnerability:** Denial of Service (DoS) vulnerability via unhandled TypeError when providing non-ASCII API keys to `hmac.compare_digest`. +**Learning:** `hmac.compare_digest` in Python raises a `TypeError` when comparing strings containing non-ASCII characters. Passing untrusted, unencoded headers directly to this function can lead to unhandled exceptions and service crashes. FastAPI/Starlette decodes headers to `latin1` strings. +**Prevention:** Always encode both arguments passed to `hmac.compare_digest` to bytes before comparison, specifically using `.encode('latin1', errors='replace')` when dealing with FastAPI/Starlette decoded HTTP headers. diff --git a/saas_web.py b/saas_web.py index 63265e94..4368e556 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("latin1", errors="replace"), key.encode("latin1", errors="replace")) 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..2faf5ae8 100644 --- a/tests/test_saas_web.py +++ b/tests/test_saas_web.py @@ -186,6 +186,17 @@ def test_shrink_media_rejects_nonpositive_target_bytes(self): {"error": "Invalid target_bytes value. Must be greater than 0."}, ) + def test_non_ascii_api_key_does_not_crash(self): + with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": "secret-key"}): + response = client.post( + "/shrink", + files={"file": ("input.wav", io.BytesIO(b"dummy wav data"), "audio/wav")}, + data={"target_bytes": 0}, + headers=[(b"x-api-key", "한글".encode("utf-8"))], + ) + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json(), {"error": "Invalid or missing API key"}) + def test_shrink_media_rejects_missing_filename(self): response = saas_web.shrink_media( BackgroundTasks(), @@ -699,6 +710,17 @@ def test_no_env_var_leaves_endpoints_open(self): {"error": "Invalid target_bytes value. Must be greater than 0."}, ) + def test_non_ascii_api_key_does_not_crash(self): + with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": "secret-key"}): + response = client.post( + "/shrink", + files={"file": ("input.wav", io.BytesIO(b"dummy wav data"), "audio/wav")}, + data={"target_bytes": 0}, + headers=[(b"x-api-key", "한글".encode("utf-8"))], + ) + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json(), {"error": "Invalid or missing API key"}) + def test_missing_header_rejected_when_keys_configured(self): with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": "secret-key"}): response = self._post_shrink() From 54eab78b14394d82c1c6a09fd1405f70fe0dc38a Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:19:22 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITIC?= =?UTF-8?q?AL]=20hmac.compare=5Fdigest=EC=9D=98=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EB=90=98=EC=A7=80=20=EC=95=8A=EC=9D=80=20TypeError=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B8=ED=95=9C=20DoS=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 | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/tests/test_saas_web.py b/tests/test_saas_web.py index 2faf5ae8..08214f25 100644 --- a/tests/test_saas_web.py +++ b/tests/test_saas_web.py @@ -710,17 +710,6 @@ def test_no_env_var_leaves_endpoints_open(self): {"error": "Invalid target_bytes value. Must be greater than 0."}, ) - def test_non_ascii_api_key_does_not_crash(self): - with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": "secret-key"}): - response = client.post( - "/shrink", - files={"file": ("input.wav", io.BytesIO(b"dummy wav data"), "audio/wav")}, - data={"target_bytes": 0}, - headers=[(b"x-api-key", "한글".encode("utf-8"))], - ) - self.assertEqual(response.status_code, 401) - self.assertEqual(response.json(), {"error": "Invalid or missing API key"}) - def test_missing_header_rejected_when_keys_configured(self): with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": "secret-key"}): response = self._post_shrink() From 2b61ddc327505162433d40f33b15d6d167ea8243 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:24:58 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITIC?= =?UTF-8?q?AL]=20hmac.compare=5Fdigest=EC=9D=98=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EB=90=98=EC=A7=80=20=EC=95=8A=EC=9D=80=20TypeError=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B8=ED=95=9C=20DoS=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit