Skip to content

Commit 8a62491

Browse files
committed
build(security): floor guard-core at 4.0.0 and fastapi-guard at 8.0.0 for redacted block logs
guard-core 4.0.0 redacts authorization, proxy-authorization, cookie and x-api-key to [REDACTED] on every guard log line and event, closing the 3.17.0 leak (proven here with a new blocked-request test). It also stops fully silencing excluded_detection_headers for non-identity headers, so x-auth-token and x-odysseus-internal-token (real secrets, not in guard-core's hardcoded redaction set) now get an explicit log_sensitive_headers entry to keep the same guarantee. fastapi-guard 8.0.0 floors guard-core>=4.0.0. Audited: no require_headers()/require_referrer() usage, enable_redis=False so the 4.0.0 rate-limit key hashing is a no-op, and no websocket routes exist under this perimeter.
1 parent d92d51e commit 8a62491

3 files changed

Lines changed: 83 additions & 10 deletions

File tree

core/guard.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,10 +271,22 @@ async def _global_content_scan(request):
271271
"context", "workspace", "approved_plan", "search_context",
272272
"target_language", "email_translate_language",
273273
},
274+
# guard-core >= 4.0.0 no longer fully silences a header listed here: it
275+
# inherits identity-header logic (skips only the ssrf category, and only
276+
# for IP-shaped values) and every other category still scans it. In
277+
# practice all six below are still scanned by SQLi/XSS/traversal/etc,
278+
# same as an unlisted header. authorization and x-api-key don't need the
279+
# entry any more since guard-core's own hardcoded sensitive-header set
280+
# redacts them to [REDACTED] in every log line regardless. x-auth-token
281+
# and x-odysseus-internal-token carry real secrets (integration API
282+
# keys, the internal tool-call token) and aren't in that hardcoded set,
283+
# so log_sensitive_headers below adds them explicitly. x-odysseus-owner
284+
# and x-tz-offset carry a username and a UTC offset, not secrets.
274285
excluded_detection_headers={
275286
"authorization", "x-api-key", "x-auth-token",
276287
"x-odysseus-internal-token", "x-odysseus-owner", "x-tz-offset",
277288
},
289+
log_sensitive_headers=frozenset({"x-auth-token", "x-odysseus-internal-token"}),
278290
# Free-text search and mailbox-folder query parameters. A user searching
279291
# their own history for "DROP TABLE" is not an attack. Path-like params
280292
# (path, filepath) stay scanned so traversal detection still applies.

requirements-optional.txt

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,16 +50,17 @@ markitdown[docx,pptx,xlsx,xls]==0.1.6
5050
# detection, honeypot auto-ban, per-route size/content-type caps, and log-only
5151
# credential-into-corpus / prompt-injection signals. Wired in core/guard.py and
5252
# only imported when the flag is set, so the default install stays unchanged.
53-
fastapi-guard>=7.8.2
54-
# guard-core >= 3.15.0 stops printing its own console line when the host app
55-
# has root handlers, so guard events reach Odysseus's console and file log
56-
# once. >= 3.16.0 closes a TTLCache check-then-use race on the IP-ban read
57-
# path that could silently skip a banned IP's block under this perimeter's
58-
# passive-mode fail_secure=False, so it is load-bearing for threat_ban_config
59-
# / auto_ban_threshold below. >= 3.17.0 adds a last-known dynamic-rules
60-
# snapshot; a no-op here, since this perimeter never enables dynamic rules
61-
# (enable_agent=False).
62-
guard-core>=3.17.0
53+
fastapi-guard>=8.0.0
54+
# guard-core >= 4.0.0 redacts authorization, proxy-authorization, cookie and
55+
# x-api-key (plus query-string and body secrets) as [REDACTED] on every guard
56+
# log line, event and hook payload, closing the leak where 3.17.0 wrote those
57+
# headers verbatim on every block (proven under TestClient in this perimeter,
58+
# see tests/test_guard_perimeter_security.py). Separately, its default
59+
# excluded-header set skips proxy identity headers (x-forwarded-for and
60+
# friends) from the ssrf category only; every other detection category
61+
# still scans them.
62+
# >= 8.0.0 floors guard-core>=4.0.0.
63+
guard-core>=4.0.0
6364
# GeoIP country lookup for ODYSSEUS_GUARD_BLOCK_COUNTRIES (needs a MaxMind
6465
# GeoLite2/GeoIP2 country .mmdb pointed to by ODYSSEUS_GUARD_GEOIP_DB). Only
6566
# used when country blocking is enabled.

tests/test_guard_perimeter_security.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,66 @@ async def search(q: str = ""):
317317
assert result.stdout.strip().endswith("OK")
318318

319319

320+
@requires_guard
321+
def test_blocked_request_log_redacts_credentials():
322+
result = _run(
323+
"""
324+
import logging
325+
326+
from fastapi import FastAPI, APIRouter, Request
327+
from starlette.testclient import TestClient
328+
from guard import SecurityMiddleware
329+
import core.guard as g
330+
331+
router = APIRouter()
332+
333+
@router.post("/api/thing")
334+
async def thing(request: Request):
335+
return {"ok": True}
336+
337+
app = FastAPI()
338+
app.add_middleware(SecurityMiddleware, config=g.security_config)
339+
app.state.guard_decorator = g.guard_deco
340+
app.include_router(router)
341+
342+
records = []
343+
handler = logging.Handler()
344+
handler.emit = lambda record: records.append(record.getMessage())
345+
guard_logger = logging.getLogger("guard_core")
346+
guard_logger.addHandler(handler)
347+
guard_logger.setLevel(logging.WARNING)
348+
349+
client = TestClient(app, client=("127.0.0.1", 12345))
350+
attack = "1' UNION SELECT password FROM users -- "
351+
resp = client.post(
352+
"/api/thing",
353+
json={"weird": attack},
354+
headers={
355+
"Authorization": "Bearer sekrit-token",
356+
"Cookie": "session=sekrit-cookie",
357+
"X-Api-Key": "sekrit-key",
358+
"X-Auth-Token": "sekrit-integration-token",
359+
"X-Odysseus-Internal-Token": "sekrit-internal-token",
360+
},
361+
)
362+
assert resp.status_code == 400, resp.status_code
363+
assert records, "no guard_core log records captured"
364+
blob = "\\n".join(records)
365+
assert "sekrit-token" not in blob, blob
366+
assert "sekrit-cookie" not in blob, blob
367+
assert "sekrit-key" not in blob, blob
368+
assert "sekrit-integration-token" not in blob, blob
369+
assert "sekrit-internal-token" not in blob, blob
370+
assert "[REDACTED]" in blob, blob
371+
print("OK")
372+
""",
373+
ODYSSEUS_GUARD_ENABLED="true",
374+
ODYSSEUS_GUARD_PASSIVE="false",
375+
)
376+
assert result.returncode == 0, result.stderr
377+
assert result.stdout.strip().endswith("OK")
378+
379+
320380
@requires_guard
321381
def test_active_mode_blocks_scanner_user_agents_and_accepts_form_routes():
322382
result = _run(

0 commit comments

Comments
 (0)