diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 9c9d083b..ae0f636d 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -1,3 +1,8 @@ +## 2026-07-28 - [Sentinel: Unhandled Exception in hmac.compare_digest] +**Vulnerability:** Unhandled Exception (CWE-754) leading to 500 Internal Server Error via non-ASCII header injection. +**Learning:** `hmac.compare_digest` throws a `TypeError` when comparing strings containing non-ASCII characters. If client headers are decoded and contain such characters, comparing them against ASCII keys causes the application to crash instead of failing securely (401). +**Prevention:** Always encode strings to `utf-8` bytes before passing them to `hmac.compare_digest` to ensure robust comparison and secure failure for malformed inputs. + ## 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..071b1419 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/test_hmac.py b/test_hmac.py new file mode 100644 index 00000000..70d20985 --- /dev/null +++ b/test_hmac.py @@ -0,0 +1,53 @@ +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient +from fastapi.responses import JSONResponse +import hmac +import traceback +import uvicorn +import httpx +import asyncio + +app = FastAPI() + +configured_keys = ["validkey"] + +@app.middleware("http") +async def require_api_key(request: Request, call_next): + provided_key = request.headers.get("x-api-key", "") + try: + if not any(hmac.compare_digest(provided_key, key) for key in configured_keys): + return JSONResponse(status_code=401, content={"error": "Invalid or missing API key"}) + except Exception as e: + print("Exception:", e) + traceback.print_exc() + return JSONResponse(status_code=500, content={"error": "Server error"}) + return await call_next(request) + +@app.get("/test") +def test(): + return {"status": "ok"} + +async def run_test(): + config = uvicorn.Config(app, port=8888, log_level="info") + server = uvicorn.Server(config) + task = asyncio.create_task(server.serve()) + await asyncio.sleep(1) # wait for server to start + + # Use raw socket to bypass httpx ascii check + import socket + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.connect(("127.0.0.1", 8888)) + + # send raw bytes + req = b"GET /test HTTP/1.1\r\nHost: 127.0.0.1:8888\r\nx-api-key: invalid\xc3\xb1\r\n\r\n" + s.sendall(req) + + resp = s.recv(4096) + print("Response:\n", resp.decode('latin1')) + s.close() + + server.should_exit = True + await task + +if __name__ == "__main__": + asyncio.run(run_test()) diff --git a/test_hmac_2.py b/test_hmac_2.py new file mode 100644 index 00000000..bdf3244b --- /dev/null +++ b/test_hmac_2.py @@ -0,0 +1,9 @@ +import hmac + +provided_key = "invalid\xc3\xb1" # simulated latin-1 decode from ASGI +key = "validkey" + +try: + hmac.compare_digest(provided_key, key) +except Exception as e: + print(e) diff --git a/test_hmac_3.py b/test_hmac_3.py new file mode 100644 index 00000000..baa1b23d --- /dev/null +++ b/test_hmac_3.py @@ -0,0 +1,10 @@ +import hmac + +provided_key = "invalid\xc3\xb1" # simulated latin-1 decode from ASGI +key = "validkey" + +try: + hmac.compare_digest(provided_key.encode('utf-8'), key.encode('utf-8')) + print("Success") +except Exception as e: + print(e)