Repository navigation
[BUG] Five small hardening gaps #81
Description
Grouped because none is worth its own thread.
1. ban_ip() stores whatever spelling the caller passed. The middleware
canonicalises before calling, so requests are fine; a ban placed through the API
with a non-canonical IPv6 form never matches anything:
ban_ip("2001:DB8::1") then is_ip_banned(...)
'2001:DB8::1' -> True
'2001:db8::1' -> False
'2001:0db8:0000:0000:0000:0000:0000:0001' -> False
An admin endpoint or a runbook that bans an address it copied out of a log gets
a ban that silently does nothing. _canonicalize_ip already exists and is
imported into ipban_handler; calling it in ban_ip, unban_ip and
is_ip_banned closes it.
2. Custom header names are not CRLF-validated.
_security_headers_config.py:116 validates the value and passes the name
through:
self.custom_headers[name] = self._validate_header_value(value){"X-Evil\r\nSet-Cookie: session=attacker": "1"} is stored as given, and
headers.update(self.custom_headers) puts it on the response.
The config path needs an operator mistake. The Redis path does not:
_security_headers_cache.py:52-53 assigns
self.custom_headers = json.loads(custom_headers) straight from the key, so
that dict skips _validate_header_value on the values as well as the names.
Anyone with write access to the shared Redis instance rewrites every response
header the app emits, including Set-Cookie.
Validate the name against the RFC 9110 token grammar, and run the Redis-loaded
config through the same validators as the constructor arguments.
3. add_status_route is unauthenticated. add_status_route(app, path)
takes no dependency or auth argument and publishes
middleware.get_initialization_status() — handler readiness, cloud-provider
entry counts, geo state — to anyone who can reach the path. It is opt-in, so
this is a documentation and API question rather than a vulnerability, but the
signature should accept dependencies= so mounting it safely does not require
wrapping it by hand.
4. 429 responses carry no Retry-After. There is no Retry-After anywhere
in the package. A well-behaved client has nothing to back off against and
retries immediately, which is the traffic pattern the limiter exists to reduce.
rate_limit_window is already known at the point the 429 is built.
5. Two SSRF forms still evade suspatterns_handler.py:477-486. The
legacy-IPv4 encodings (hex, octal, decimal, short form) are all correctly
blocked; these two are not:
http://[::ffff:127.0.0.1]/ -> not detected
http://localhost./ -> not detected
http://127.0.0.1/ (control) -> detected
Both resolve to loopback in every client library I know of. Adding the
IPv4-mapped IPv6 literal form and tolerating a trailing dot on the hostname
covers them.
4. Checked and deliberately not filed
[29]weight-0.5 SQLi patterns cannot cross the 1.0 threshold alone.
SELECT password FROM usersin a parameter scores 0.5 and is not a threat.
Verified still true, but it is a scoring tradeoff the maintainer chose, and
raising it trades for false positives on any app with a query builder.[46]passive_mode=Truealso disables emergency lockdown.
emergency_mode.py:61,68gate enforcement onnot passive_mode. Arguable
either way — passive means observe-only, and honouring that consistently is
defensible. Worth a docs sentence, not a report.[49]unknownSecurityConfigkeys warn rather than raise. 3.13.0 added
a "did you mean" to the warning, which is most of the value.extra="forbid"
would be better but is a breaking change.[52]raw request path as the OTelendpointtag — cardinality and PII
in metrics. Real, but it is an operability bug in an optional integration.[53]route_resolution_strict=Falsedefault — documented, and the
global checks still run.[40]SecurityHeadersManageris a process-wide singleton — only bites
with two apps in one process, which is unusual enough to leave.[57]release workflow uses long-lived PyPI credentials and floating
third-party actions. Worth doing (Trusted Publishing, SHA-pinned actions),
but it is unsolicited advice about someone's CI rather than a finding, and it
reads badly alongside twelve bug reports.semantic._check_ast_parsing_riskbuilds a freshThreadPoolExecutorper
scanned value under 1 000 chars. Measured: 0.86 ms of the 20.66 ms a short
value costs, so 4%. Real waste, wrong order of magnitude to matter. Belongs as
a line in GHSA-3hfx-8m47-5f9h if that one gets a fix discussion, not as its
own report.
5. Files
docs/upstream-guard-core/followups/triage.py |
re-runs all 34 checks, prints its own evidence |
poc_xff_chain.py |
Draft B |
poc_missing_client.py |
Draft C |
poc_websocket.py |
Draft A |
poc_logging.py |
Draft D |
poc_behavior_tracker.py |
Draft E |
poc_failopen_config.py |
Draft I |
Drafts F, G, H, J, K and L reproduce from the snippets inline; triage.py
covers them all.
Found while re-checking a private audit against the current release. Verified on guard-core 3.13.0, CPython 3.14.
Activity
rennf93 commentedon Aug 25, 2026
Thanks @RomanPszonka for opening the issue! By @rennf93's directive:
Item by item:
ban_ipspelling: reproduced, and it is worse than you wrote.unban_ipwith a different spelling of a banned address is a silent no-op, so a ban placed from a log line cannot be lifted with the canonical form either. Same on the Redis path.ban_ip,unban_ipandis_ip_bannedcanonicalise in 3.14.0.SecurityHeadersManager.initialize_redis(), so the Redis path is only live when the host app calls it; it gets validated regardless.add_status_routeis fastapi-guard code, not guard-core's. Tracked there; it takes adependencies=parameter in 7.8.0.Retry-After: yes,windowis in scope where the 429 is built. Added in 3.14.0 as the window length (the sliding windows do not expose the oldest surviving timestamp cheaply, so this is coarse by design).detect_penetration_attempt, query string and JSON body. Small correction: the SSRF pattern is atsuspatterns_handler.pyaround 2064-2073; 477-486 is LDAP code. The fix is scoped tolocalhostfollowed by an optional dot at the host boundary only, because the benign corpus includeslocalhost.example.comandnotlocalhost.io, plus the IPv4-mapped IPv6 loopback literal.On the "checked and deliberately not filed" list, one correction: the "did you mean" hint on unknown
SecurityConfigkeys landed in 3.11.0 (PR #63), not 3.13.0. The rest checked out.rennf93 commentedon Aug 26, 2026
Shipped in guard-core 3.14.0 (PyPI), merged in #82 (500d4ef).
ban_ip,unban_ipandis_ip_bannedcanonicalise the address on both backends; custom security-header names (Redis-loaded ones included) are validated against the RFC 9110 token grammar and rejected on CRLF;ssrfnow matcheshttp://[::ffff:127.0.0.1]/andhttp://localhost./; every 429 carriesRetry-Afterand the in-memory rate-limit and behaviour stores are LRU-bounded. The status-routedependencies=parameter is in fastapi-guard 7.8.0 (Guard-Core/fastapi-guard#121, releasing next).