diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 9c9d083b..5842b5ea 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -1,3 +1,8 @@ +## 2026-09-01 - [Sentinel: Fix hmac.compare_digest TypeError DoS Vulnerability] +**Vulnerability:** Denial of Service (DoS) via unhandled `TypeError` exceptions. +**Learning:** `hmac.compare_digest` raises a `TypeError` if called with string arguments where either string contains non-ASCII characters. Passing unvalidated user input (like HTTP headers) directly to this function can cause a server crash (500 Internal Server Error) when processing malicious input. +**Prevention:** Always encode string inputs to `utf-8` bytes before passing them to `hmac.compare_digest` to ensure safe, constant-time comparison regardless of character encoding. + ## 2026-07-25 - [Cross-platform upload basename normalization] **Behavior:** Upload metadata now interprets both forward slashes and backslashes as path separators before extracting a basename. **Learning:** On POSIX systems, `pathlib.Path(filename).name` retains backslashes because they are ordinary characters there. That caused inconsistent manifest and converter filenames for Windows-style client paths. The upload itself is still written inside a trusted temporary workspace, and batch archive entry names are generated outputs; this change does not establish a filesystem traversal or archive-entry escape. diff --git a/saas_web.py b/saas_web.py index 63265e94..5b280f05 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..5a470c8b 100644 --- a/tests/test_saas_web.py +++ b/tests/test_saas_web.py @@ -707,6 +707,23 @@ 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_safely(self): + import asyncio + from fastapi import Request + from saas_web import require_api_key + + scope = {'type': 'http', 'method': 'POST', 'path': '/shrink', 'headers': [(b'x-api-key', '안녕'.encode('utf-8'))]} + request = Request(scope) + + async def call_next(req): + pass + + with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": "secret-key"}): + response = asyncio.run(require_api_key(request, 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"})