diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 9c9d083b..eb9ea7b7 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-08-27 - [Sentinel: API Key DoS via hmac.compare_digest] +**Vulnerability:** Denial of Service (DoS) due to unhandled exceptions when passing non-ASCII string headers to `hmac.compare_digest` (CWE-400 / Uncontrolled Resource Consumption). +**Learning:** `hmac.compare_digest` throws a `TypeError` if provided strings contain non-ASCII characters. Since Starlette extracts HTTP headers as strings and passes them to authentication middleware, an attacker can crash the server on a per-request basis by sending arbitrary non-ASCII characters (like emojis) in the `x-api-key` header. +**Prevention:** Always encode user-controlled strings (like HTTP headers or tokens) to bytes using `.encode("utf-8")` before comparing them using cryptographic functions like `hmac.compare_digest`. diff --git a/saas_web.py b/saas_web.py index 63265e94..4c123b85 100644 --- a/saas_web.py +++ b/saas_web.py @@ -114,7 +114,8 @@ 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..5a3241f9 100644 --- a/tests/test_saas_web.py +++ b/tests/test_saas_web.py @@ -707,6 +707,32 @@ 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) + @unittest.skipUnless( + _HAS_FASTAPI, "fastapi not installed (optional integration dependency)" + ) + def test_non_ascii_key_rejected_safely(self): + from starlette.requests import Request + from saas_web import require_api_key + import asyncio + + async def mock_call_next(request): + return "SUCCESS" + + with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": "secret-key"}): + scope = { + "type": "http", + "method": "POST", + "url": "http://testserver/upload", + "path": "/upload", + "headers": [(b"x-api-key", "test🌟".encode("utf-8"))] + } + request = Request(scope) + response = asyncio.run(require_api_key(request, mock_call_next)) + + self.assertEqual(response.status_code, 401) + import json + 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"})