diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 9c9d083b..39fc1121 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-20 - [Sentinel: Unhandled Exception DoS in HMAC Comparison] +**Vulnerability:** Uncontrolled Resource Consumption (DoS) via Unhandled `TypeError` Exception (CWE-400 / CWE-754) when validating API keys. +**Learning:** Python's `hmac.compare_digest` function throws a `TypeError: comparing strings with non-ASCII characters is not supported` if either string argument contains non-ASCII characters. Because the `X-API-Key` HTTP header is user-controlled, an attacker can send a request with a non-ASCII key (e.g., `X-API-Key: ö`), causing the FastAPI application to crash with a 500 Internal Server Error, bypassing the intended 401 Unauthorized response and potentially leading to a Denial of Service. +**Prevention:** Always encode user-controlled strings to bytes (e.g., `.encode("utf-8")`) before passing them to `hmac.compare_digest` to ensure safe, constant-time comparison regardless of the input character set. diff --git a/saas_web.py b/saas_web.py index 63265e94..53b7e923 100644 --- a/saas_web.py +++ b/saas_web.py @@ -112,9 +112,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 = request.headers.get("x-api-key", "").encode("utf-8") if not any( - hmac.compare_digest(provided_key, key) for key in configured_keys + hmac.compare_digest(provided_key, 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..78aa4af1 100644 --- a/tests/test_saas_web.py +++ b/tests/test_saas_web.py @@ -28,6 +28,34 @@ client = TestClient(app) +@unittest.skipUnless( + _HAS_FASTAPI, "fastapi not installed (optional integration dependency)" +) +class TestApiKeyAuthDoSMitigation(unittest.IsolatedAsyncioTestCase): + async def test_non_ascii_header_does_not_crash(self): + from fastapi import Request + import saas_web + + # Setup a mock request with a non-ASCII API key header + scope = { + "type": "http", + "method": "POST", + "path": "/shrink", + "headers": [(b"x-api-key", "wrong-key-ö".encode("utf-8"))], + } + request = Request(scope) + + async def mock_call_next(request): + return "SUCCESS" + + with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": "secret-key"}): + response = await saas_web.require_api_key(request, mock_call_next) + + self.assertEqual(response.status_code, 401) + body = json.loads(response.body.decode('utf-8')) + self.assertEqual(body["error"], "Invalid or missing API key") + + @unittest.skipUnless( _HAS_FASTAPI, "fastapi not installed (optional integration dependency)" )