Skip to content

[BUG] Five small hardening gaps #81

Closed

Description

@RomanPszonka

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 users in 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=True also disables emergency lockdown.
    emergency_mode.py:61,68 gate enforcement on not passive_mode. Arguable
    either way — passive means observe-only, and honouring that consistently is
    defensible. Worth a docs sentence, not a report.
  • [49] unknown SecurityConfig keys 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 OTel endpoint tag — cardinality and PII
    in metrics. Real, but it is an operability bug in an optional integration.
  • [53] route_resolution_strict=False default — documented, and the
    global checks still run.
  • [40] SecurityHeadersManager is 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_risk builds a fresh ThreadPoolExecutor per
    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

  1. rennf93 commented on Aug 25, 2026

    @rennf93
    Member

    Thanks @RomanPszonka for opening the issue! By @rennf93's directive:

    Item by item:

    1. ban_ip spelling: reproduced, and it is worse than you wrote. unban_ip with 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_ip and is_ip_banned canonicalise in 3.14.0.
    2. Header names: reproduced on both paths. Name validation against the RFC 9110 token grammar, and the Redis-loaded config goes through the same validators as the constructor path. For the record, guard-core's own init flow never calls SecurityHeadersManager.initialize_redis(), so the Redis path is only live when the host app calls it; it gets validated regardless.
    3. add_status_route is fastapi-guard code, not guard-core's. Tracked there; it takes a dependencies= parameter in 7.8.0.
    4. Retry-After: yes, window is 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).
    5. SSRF: both forms reproduced on the wire through detect_penetration_attempt, query string and JSON body. Small correction: the SSRF pattern is at suspatterns_handler.py around 2064-2073; 477-486 is LDAP code. The fix is scoped to localhost followed by an optional dot at the host boundary only, because the benign corpus includes localhost.example.com and notlocalhost.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 SecurityConfig keys landed in 3.11.0 (PR #63), not 3.13.0. The rest checked out.

  2. self-assigned this
    on Aug 25, 2026
  3. added
    bugConfirmed defect
    securitySecurity-related issue (use private reporting first!)
    area: handlersTouches guard_core/handlers/
    on Aug 26, 2026
  4. linked a pull request that will close this issuerelease(v3.14.0): post-3.13.0 hardening #82on Aug 26, 2026
  5. rennf93 commented on Aug 26, 2026

    @rennf93
    Member

    Shipped in guard-core 3.14.0 (PyPI), merged in #82 (500d4ef).

    ban_ip, unban_ip and is_ip_banned canonicalise 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; ssrf now matches http://[::ffff:127.0.0.1]/ and http://localhost./; every 429 carries Retry-After and the in-memory rate-limit and behaviour stores are LRU-bounded. The status-route dependencies= parameter is in fastapi-guard 7.8.0 (Guard-Core/fastapi-guard#121, releasing next).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area: handlersTouches guard_core/handlers/bugConfirmed defectsecuritySecurity-related issue (use private reporting first!)

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions