Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@

### Fixed
- ๋‹จ์ผยท์ผ๊ด„ ๋Œ€์ƒ ํฌ๊ธฐ ์ž…๋ ฅ์„ ๋น„์› ์„ ๋•Œ ์ด์ „ custom validity์™€ `aria-invalid` ์ƒํƒœ๋ฅผ ์ฆ‰์‹œ ์ดˆ๊ธฐํ™”ํ•ด ํ˜„์žฌ ํ•„์ˆ˜ ์ž…๋ ฅ ์ƒํƒœ๋ฅผ ์ •ํ™•ํžˆ ์ „๋‹ฌํ•ฉ๋‹ˆ๋‹ค.
- `x-api-key` ํ—ค๋”์— ASCII๊ฐ€ ์•„๋‹Œ ๋ฌธ์ž๊ฐ€ ํฌํ•จ๋  ๋•Œ `hmac.compare_digest`์—์„œ ๋ฐœ์ƒํ•˜๋˜ `TypeError`๋ฅผ ์ˆ˜์ •ํ–ˆ์Šต๋‹ˆ๋‹ค.
2 changes: 1 addition & 1 deletion saas_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐Ÿ”’ Security & Privacy | ๐ŸŸ  Major | ๐Ÿ—๏ธ Heavy lift

๋Ÿฐํƒ€์ž„ API ํ‚ค ์†Œ์Šค๋ฅผ credential registry/KV๋กœ ์ด์ „ํ•˜์„ธ์š”.

saas_web.py์˜ get_configured_api_keys()๋Š” Line 97์—์„œ CODEC_CARVER_API_KEYS๋ฅผ ์ง์ ‘ ์ฝ์Šต๋‹ˆ๋‹ค. ์ƒˆ ์ธ์ฆ ํ๋ฆ„๋„ ์ด ๊ฐ’์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. ํ™˜๊ฒฝ ๋ณ€์ˆ˜์—์„œ ์ง์ ‘ ์ฝ๋Š” ๋ฐฉ์‹์€ ํ‚ค ํšŒ์ „, ์ ‘๊ทผ ์ œ์–ด, ๊ฐ์‚ฌ ๊ฒฝ๋กœ๋ฅผ ์šฐํšŒํ•ฉ๋‹ˆ๋‹ค.

  • saas_web.py#L117-L117: get_configured_api_keys()๊ฐ€ credential registry/KV์—์„œ ๋Ÿฐํƒ€์ž„ ํ‚ค๋ฅผ ์ฝ๋„๋ก ๋ณ€๊ฒฝํ•˜์„ธ์š”.
  • tests/test_saas_web.py#L642-L652: ํ™˜๊ฒฝ ๋ณ€์ˆ˜๋ฅผ ํŒจ์น˜ํ•˜์ง€ ๋ง๊ณ , ํ…Œ์ŠคํŠธ์šฉ registry/KV์— ํ‚ค๋ฅผ ์„ค์ •ํ•˜์„ธ์š”.

As per coding guidelines, โ€œsaas_web.py must source runtime API keys, database credentials, endpoints, and other secrets from the credential registry/KV rather than directly from environment variables.โ€

๐Ÿ“ Affects 2 files
  • saas_web.py#L117-L117 (this comment)
  • tests/test_saas_web.py#L642-L652
๐Ÿค– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@saas_web.py` at line 117, Update saas_web.py lines 117-117 in
get_configured_api_keys() to load runtime API keys from the credential
registry/KV instead of directly reading CODEC_CARVER_API_KEYS from the
environment, while preserving the existing key comparison behavior. Update
tests/test_saas_web.py lines 642-652 to configure the test API key in the test
registry/KV rather than patching the environment variable.

Source: Coding guidelines

):
return JSONResponse(
status_code=401,
Expand Down
11 changes: 11 additions & 0 deletions test_direct.py
Original file line number Diff line number Diff line change
@@ -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)
10 changes: 10 additions & 0 deletions test_encode.py
Original file line number Diff line number Diff line change
@@ -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))
33 changes: 33 additions & 0 deletions test_hmac_direct.py
Original file line number Diff line number Diff line change
@@ -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)
44 changes: 44 additions & 0 deletions test_hmac_direct2.py
Original file line number Diff line number Diff line change
@@ -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()
Comment on lines +1 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐Ÿฉบ Stability & Availability | ๐ŸŸ  Major | โšก Quick win

์‹คํ—˜ ์žฌํ˜„ ํŒŒ์ผ์„ ํ…Œ์ŠคํŠธ ์ˆ˜์ง‘ ๊ฒฝ๋กœ์—์„œ ์ œ๊ฑฐํ•˜์„ธ์š”.

์ด ํŒŒ์ผ๋“ค์€ test_*.py ์ด๋ฆ„์„ ์‚ฌ์šฉํ•˜๊ณ  ๋ชจ๋“ˆ import ์‹œ ์ฆ‰์‹œ ์‹คํ–‰๋ฉ๋‹ˆ๋‹ค. ํ…Œ์ŠคํŠธ ์ˆ˜์ง‘ ์ค‘์— ์˜ˆ์™ธ, ๋„คํŠธ์›Œํฌ ๋Œ€๊ธฐ, ํฌํŠธ ์ถฉ๋Œ, Uvicorn ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์„œ๋ฒ„๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๊ฒ€์ฆ์€ ์ด๋ฏธ tests/test_saas_web.py์˜ ํšŒ๊ท€ ํ…Œ์ŠคํŠธ๋กœ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค.

  • test_hmac_direct2.py#L1-L44: Uvicorn ์„œ๋ฒ„์™€ raw socket ์žฌํ˜„ ์ฝ”๋“œ๋ฅผ ์‚ญ์ œํ•˜์„ธ์š”.
  • test_encode.py#L1-L10: import ์‹œ ์‹คํ–‰๋˜๋Š” ๋น„๊ต ์ฝ”๋“œ๋ฅผ ์‚ญ์ œํ•˜์„ธ์š”.
  • test_hmac_encode.py#L1-L6: import ์‹œ ์‹คํ–‰๋˜๋Š” ๋น„๊ต ์ฝ”๋“œ๋ฅผ ์‚ญ์ œํ•˜์„ธ์š”.
  • test_hmac_exception.py#L1-L7: import ์‹œ ์‹คํ–‰๋˜๋Š” ์ทจ์•ฝ ๋น„๊ต ์ฝ”๋“œ๋ฅผ ์‚ญ์ œํ•˜์„ธ์š”.
  • test_hmac_header.py#L1-L27: import ์‹œ ์‹คํ–‰๋˜๋Š” FastAPI ์žฌํ˜„ ์ฝ”๋“œ๋ฅผ ์‚ญ์ œํ•˜์„ธ์š”.
  • test_hmac_latin1.py#L1-L27: import ์‹œ ์‹คํ–‰๋˜๋Š” FastAPI ์žฌํ˜„ ์ฝ”๋“œ๋ฅผ ์‚ญ์ œํ•˜์„ธ์š”.
  • test_hmac_direct.py#L1-L33: import ์‹œ ์‹คํ–‰๋˜๋Š” localhost ์š”์ฒญ ์ฝ”๋“œ๋ฅผ ์‚ญ์ œํ•˜์„ธ์š”.
  • test_direct.py#L1-L11: import ์‹œ ์‹คํ–‰๋˜๋Š” ์š”์ฒญ ์ฝ”๋“œ๋ฅผ ์‚ญ์ œํ•˜์„ธ์š”.
๐Ÿงฐ Tools
๐Ÿช› Ruff (0.16.1)

[warning] 21-21: Do not catch blind exception: Exception

(BLE001)


[warning] 22-22: Use explicit conversion flag

Replace with conversion flag

(RUF010)

๐Ÿ“ Affects 8 files
  • test_hmac_direct2.py#L1-L44 (this comment)
  • test_encode.py#L1-L10
  • test_hmac_encode.py#L1-L6
  • test_hmac_exception.py#L1-L7
  • test_hmac_header.py#L1-L27
  • test_hmac_latin1.py#L1-L27
  • test_hmac_direct.py#L1-L33
  • test_direct.py#L1-L11
๐Ÿค– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test_hmac_direct2.py` around lines 1 - 44, Remove the import-time
reproduction scripts from test_hmac_direct2.py (1-44), test_encode.py (1-10),
test_hmac_encode.py (1-6), test_hmac_exception.py (1-7), test_hmac_header.py
(1-27), test_hmac_latin1.py (1-27), test_hmac_direct.py (1-33), and
test_direct.py (1-11); delete each fileโ€™s executable comparison, FastAPI/Uvicorn
server, raw socket, or localhost request code so these files cannot run during
test collection, relying on tests/test_saas_web.py for regression coverage.

6 changes: 6 additions & 0 deletions test_hmac_encode.py
Original file line number Diff line number Diff line change
@@ -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)}")
7 changes: 7 additions & 0 deletions test_hmac_exception.py
Original file line number Diff line number Diff line change
@@ -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)}")
27 changes: 27 additions & 0 deletions test_hmac_header.py
Original file line number Diff line number Diff line change
@@ -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!")
27 changes: 27 additions & 0 deletions test_hmac_latin1.py
Original file line number Diff line number Diff line change
@@ -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!")
11 changes: 11 additions & 0 deletions tests/test_saas_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand Down
Loading