diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 858d9d42..b72e7612 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -1,3 +1,8 @@ +## 2026-08-09 - [Sentinel: Unhandled TypeError in hmac.compare_digest] +**Vulnerability:** Unhandled Exception (CWE-754) / DoS via non-ASCII characters in `hmac.compare_digest`. +**Learning:** `hmac.compare_digest` requires ASCII-only strings or bytes. Passing an HTTP header containing non-ASCII characters (parsed as a Python string by ASGI servers like Uvicorn) causes a `TypeError`, resulting in an unhandled 500 error instead of a secure 401 rejection. +**Prevention:** When comparing potentially untrusted strings using `hmac.compare_digest`, encode both strings to bytes (e.g., using `.encode("utf-8")`) beforehand to safely handle any character input and prevent runtime exceptions. + ## 2026-05-28 - [Sentinel Fixes: Temp Files & Injection] **Vulnerability:** Predictable Temp Files (CWE-377) and Insecure Default Permissions (CWE-276), plus Command Injection via FFmpeg Filtergraph (CWE-20). **Learning:** Python's `Path.with_name` plus a suffix string to make a temp file opens a race condition because it's predictable and the permissions default to system `umask` which might expose secret `0600` data. Additionally, interpolating variables directly into FFmpeg filtergraph strings allows arbitrary filter injection. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cca1ced..1f1e16cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,3 +11,4 @@ ### Fixed - 단일·일괄 대상 크기 입력을 비웠을 때 이전 custom validity와 `aria-invalid` 상태를 즉시 초기화해 현재 필수 입력 상태를 정확히 전달합니다. +- `x-api-key` 헤더에 ASCII가 아닌 문자가 포함될 때 `hmac.compare_digest`에서 발생하던 `TypeError`를 수정했습니다. diff --git a/saas_web.py b/saas_web.py index 0ef95a1e..192ec5fd 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_direct.py b/test_direct.py new file mode 100644 index 00000000..e5f6b575 --- /dev/null +++ b/test_direct.py @@ -0,0 +1,11 @@ +from fastapi import Request +from starlette.testclient import TestClient +from saas_web import app +import os +from unittest.mock import patch + +with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": "secret-key"}): + client = TestClient(app) + req = client.build_request("POST", "/shrink", headers=[(b"x-api-key", b"\xff")]) + response = client.send(req) + print(response.status_code) diff --git a/test_encode.py b/test_encode.py new file mode 100644 index 00000000..c3561af4 --- /dev/null +++ b/test_encode.py @@ -0,0 +1,10 @@ +import hmac + +provided_key = "\xff" +configured_keys = ["secret-key"] + +try: + any(hmac.compare_digest(provided_key.encode("utf-8"), key.encode("utf-8")) for key in configured_keys) + print("UTF-8 works") +except Exception as e: + print(repr(e)) diff --git a/test_hmac_direct.py b/test_hmac_direct.py new file mode 100644 index 00000000..7a27425c --- /dev/null +++ b/test_hmac_direct.py @@ -0,0 +1,33 @@ +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient +from fastapi.responses import JSONResponse +import hmac +import os + +app = FastAPI() + +@app.middleware("http") +async def require_api_key(request: Request, call_next): + configured_keys = ["secret-key"] + 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"}) + except Exception as e: + return JSONResponse(status_code=500, content={"error": repr(e)}) + return await call_next(request) + +@app.get("/") +def read_root(): + return {"Hello": "World"} + +client = TestClient(app, raise_server_exceptions=False) +import requests +# Using requests to bypass httpx's strict ASCII header checks to see if the server handles it +try: + response = requests.get("http://localhost:8000/", headers={"x-api-key": "안녕"}) + print(f"Status Code: {response.status_code}") +except Exception as e: + print(e) diff --git a/test_hmac_direct2.py b/test_hmac_direct2.py new file mode 100644 index 00000000..3dc9fe8d --- /dev/null +++ b/test_hmac_direct2.py @@ -0,0 +1,44 @@ +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +import hmac +import os +import uvicorn +import threading +import time +import socket + +app = FastAPI() + +@app.middleware("http") +async def require_api_key(request: Request, call_next): + configured_keys = ["secret-key"] + 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"}) + except Exception as e: + print(f"Server caught exception: {repr(e)}") + return JSONResponse(status_code=500, content={"error": repr(e)}) + return await call_next(request) + +@app.get("/") +def read_root(): + return {"Hello": "World"} + +def run_server(): + uvicorn.run(app, host="127.0.0.1", port=8000, log_level="error") + +t = threading.Thread(target=run_server, daemon=True) +t.start() +time.sleep(1) + +# Manually send a raw HTTP request with non-ASCII header +req = b"GET / HTTP/1.1\r\nHost: localhost:8000\r\nx-api-key: \xff\r\n\r\n" +s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +s.connect(("127.0.0.1", 8000)) +s.sendall(req) +resp = s.recv(4096) +print(resp.decode("latin-1")) +s.close() diff --git a/test_hmac_encode.py b/test_hmac_encode.py new file mode 100644 index 00000000..e8a11a88 --- /dev/null +++ b/test_hmac_encode.py @@ -0,0 +1,6 @@ +import hmac +try: + hmac.compare_digest("hello".encode('utf-8'), "안녕".encode('utf-8')) + print("Bytes work") +except Exception as e: + print(f"Exception: {repr(e)}") diff --git a/test_hmac_exception.py b/test_hmac_exception.py new file mode 100644 index 00000000..5782743e --- /dev/null +++ b/test_hmac_exception.py @@ -0,0 +1,7 @@ +import hmac +try: + hmac.compare_digest("hello", "world") + print("ASCII works") + hmac.compare_digest("hello", "안녕") +except Exception as e: + print(f"Exception: {repr(e)}") diff --git a/test_hmac_header.py b/test_hmac_header.py new file mode 100644 index 00000000..56b32c07 --- /dev/null +++ b/test_hmac_header.py @@ -0,0 +1,27 @@ +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient +from fastapi.responses import JSONResponse +import hmac +import os + +app = FastAPI() + +@app.middleware("http") +async def require_api_key(request: Request, call_next): + configured_keys = ["secret-key"] + provided_key = request.headers.get("x-api-key", "") + if not any( + hmac.compare_digest(provided_key, key) for key in configured_keys + ): + return JSONResponse(status_code=401, content={"error": "Invalid"}) + return await call_next(request) + +@app.get("/") +def read_root(): + return {"Hello": "World"} + +client = TestClient(app) +response = client.get("/", headers={"x-api-key": "안녕"}) +print(f"Status Code: {response.status_code}") +if response.status_code == 500: + print("Vulnerability confirmed!") diff --git a/test_hmac_latin1.py b/test_hmac_latin1.py new file mode 100644 index 00000000..5df5ae3f --- /dev/null +++ b/test_hmac_latin1.py @@ -0,0 +1,27 @@ +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient +from fastapi.responses import JSONResponse +import hmac +import os + +app = FastAPI() + +@app.middleware("http") +async def require_api_key(request: Request, call_next): + configured_keys = ["secret-key"] + provided_key = request.headers.get("x-api-key", "") + if not any( + hmac.compare_digest(provided_key, key) for key in configured_keys + ): + return JSONResponse(status_code=401, content={"error": "Invalid"}) + return await call_next(request) + +@app.get("/") +def read_root(): + return {"Hello": "World"} + +client = TestClient(app, raise_server_exceptions=False) +response = client.get("/", headers={"x-api-key": b"\xff".decode("latin-1")}) +print(f"Status Code: {response.status_code}") +if response.status_code == 500: + print("Vulnerability confirmed!") diff --git a/tests/test_saas_web.py b/tests/test_saas_web.py index cd45dbc3..d36466ff 100644 --- a/tests/test_saas_web.py +++ b/tests/test_saas_web.py @@ -639,6 +639,17 @@ def test_wrong_key_rejected(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): + with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": "secret-key"}): + # httpx test client refuses to send non-ASCII strings as headers; + # we must bypass its validation by explicitly passing raw bytes for the request + # to simulate an attacker sending raw bytes over the wire. + req = client.build_request("POST", "/shrink", headers=[(b"x-api-key", b"\xff")]) + response = client.send(req) + + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json(), {"error": "Invalid or missing API key"}) + def test_correct_key_reaches_handler(self): with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": "secret-key"}): response = self._post_shrink(headers={"X-API-Key": "secret-key"})