Repository navigation
v3.15.0
Post-3.14.0 follow-ups: redis_fail_open honored by the rate limiter, a depth-bounded JSON body walk and per-request scan-char budget, corrected X-Forwarded-For depth over-counting and oversized-body scanning, no more double-logging into host handlers, and a rank-A internal module split (v3.15.0)
Special thanks to @RomanPszonka for the reports and advisories behind this release and the support throughout. Thank you for making the Guard Core ecosystem better!
Highlights
- Breaking: the rate limiter now honors
redis_fail_openon a Redis failure instead of always falling back to the in-memory window.redis_fail_open=Truestill falls back to the in-memory window but now logs aWARNINGonce per process instead of anERRORon every request;redis_fail_open=False(the default) now raisesGuardRedisError, letting the pipeline applyfail_secureexactly as for any other check's Redis failure instead of silently degrading a shared rate limit to a per-process one. - Bounded JSON body walk and per-request scan-char budget. A new
SecurityConfig.detection_max_json_depth(default32) rewrites the JSON body walk as a depth-bounded, recursion-free iteration instead of one Python call per nesting level, and a subtree reached at that depth is serialized back to text and scanned as one value (GHSA-f6cf-jjhc-qp85); a newSecurityConfig.detection_max_scan_chars(default65536) separately bounds the total characters handed to the pattern engine per request (GHSA-3hfx-8m47-5f9h residual). - X-Forwarded-For over-count correction. A declared
trusted_proxy_depththat over-counts the real proxy hops let a client rotate its resolved identity freely; guard-core now verifies every entry to the right of the depth-selected one is itself a listed trusted proxy and, when it is not, resolves identity by walking the chain right to left instead, warning once per process (GHSA-8xvm-856x-7hwp claim 1 residual). - Oversized-body scanning. A body whose declared
Content-Lengthexceedsdetection_max_body_inspect_bytesis no longer silently skipped when the adapter supports a bounded read; the firstdetection_max_body_inspect_bytesbytes are read through it and scanned instead, keeping the existing single-memory-bound guarantee (GHSA-3hfx-8m47-5f9h residual). - Auto-configuring detection singleton.
detect_penetration_attempt(request, config)now configures the detection singleton fromconfigitself whenever it is unconfigured or was last configured from a different config object, so a direct caller (guard-core-mcp'scheck_payload, a PoC script, a test written against this function directly) runs the same enhanced detection path an adapter runs instead of the slower legacy fallback. - No more double-logging into host handlers.
setup_custom_logging's console handler now carries a filter that checks the host's root logger handlers at the moment each record is emitted, yielding to them whenever they exist instead of printing every guard security event twice when a host has already configured its own root logging. - Internal module split, rank A across the board.
suspatterns_handler.py,handler_initializer.py,_security_config_validators.pyand_redos_structure.pyare restructured into cohesive private sibling modules, with every previously importable name re-exported from its original module path; detection results, sync mirrors, and behavior are unchanged. - Bug-hunt fixes. A signature straddling the oversized-body scan boundary is now caught the same way whether or not the client declared an oversized
Content-Length; the syncBehaviorTrackerstores are now thread-locked, closing aRuntimeErrorunder a real WSGI thread pool; anX-Forwarded-Forentry carrying a port (1.2.3.4:5678,[2001:db8::1]:5678) is now parsed instead of discarding the whole header.
Added
SecurityConfignow warns at construction whenwhitelistcontains a/0network. Awhitelistentry of0.0.0.0/0or::/0makes every address whitelisted, soblacklist,blocked_countriesand IP bans can never block anyone; this was previously silent. Precedence and every access decision are unchanged, a/0whitelist still allows everyone, this is a signal only (#79).
Changed
PerformanceMonitor.record_metricno longer recomputes its running average withstatistics.mean's exact-Fractionarithmetic. Profiling thedetection_max_scan_charsbudget's worst case (28 values x 9342 characters) found this call, made once per pattern check (about 8600 times for that request), accounted for roughly 30% of enhanced-mode detection CPU;stats.avg_execution_timeis nowmath.fsum(stats.recent_times) / len(stats.recent_times), the same approachmonitor_anomalies.pyalready used elsewhere, correct to within ordinary floating-point rounding. First of three constant-factor cuts named in the profile report for GHSA-3hfx-8m47-5f9h; none change any detection decision.SemanticAnalyzer.analyzeno longer tokenizes its input twice.analyze_attack_probabilityand thetoken_countfield both calledextract_tokensindependently on the same content;analyzenow extracts tokens once and reuses them for both. Second of the three constant-factor cuts.- The url-decoded detection view no longer re-runs percent/unicode decoding from scratch.
_check_url_decoded_view_patternscalledpreprocess_url_decoded_newline_preserving, which independently repeateddecode_common_encodingson the raw content even when the main preprocessing pass had already decoded it, or found nothing to decode; it now reuses the main pass's decoded intermediate (ContentPreprocessor.preprocess_with_decoded) instead of decoding twice. Third of the three constant-factor cuts. - Breaking: the rate limiter now honors
redis_fail_openon a Redis failure instead of always falling back to the in-memory window._redis_request_count(check_rate_limitandcheck_rate_limit_by_ip,guard_core/handlers/ratelimit_handler.py) previously caught every Redis error itself and silently used the in-memory window regardless ofredis_fail_open, so a Redis outage silently turned a shared, cross-worker rate limit into a per-process one (N workers effectively multiplyingrate_limitby N) with no way to opt out.redis_fail_open=Truestill falls back to the in-memory window, but now logs aWARNINGonce per process ("Redis unavailable for rate limiting...") instead of anERRORon every request.redis_fail_open=False(the default) now raisesGuardRedisErrorinstead of silently falling back:check_rate_limitletsSecurityCheckPipeline._handle_check_errorapplyfail_secureexactly as for any other check's Redis failure (500 underfail_secure=True, pass-through otherwise), andcheck_rate_limit_by_iplets the exception reach its own caller. A deployment relying on the previous always-fall-back behavior with the defaultredis_fail_open=Falsenow needs to setredis_fail_open=Trueexplicitly to keep it.NoScriptErrorreload handling and the auto-ban feed are unchanged. - Internal module split, no public name changes.
guard_core/handlers/suspatterns_handler.py,guard_core/core/initialization/handler_initializer.py,guard_core/_security_config_validators.pyandguard_core/detection_engine/_redos_structure.pywere each at maintainability rank below A; every one is restructured into cohesive private sibling modules (guard_core/handlers/_suspatterns_*.py,guard_core/detection_engine/_redos_structure_*.py, and similarly for the other two), with every previously importable name re-exported from its original module path. Detection results, sync mirrors, and behavior are unchanged.
Fixed
- A signature straddling the
detection_max_body_inspect_bytescap boundary was missed when the client declared an oversizedContent-Length, but caught when it did not._read_capped_body_prefix(no declaredContent-Length, or one at or under the cap) fetchedmax_bytesplus a small overlap (the longest compiled pattern length, up to 256 bytes) so a signature split across the cap boundary was still scanned in full;_read_oversized_declared_body(a declaredContent-Lengthover the cap, added in the previous fix wave) read exactlymax_byteswith no overlap, so the identical body missed detection on that path alone. Both paths now compute the fetch size through one shared helper,_capped_body_fetch_size. Sync mirror regenerated. CloudManager.refresh_asyncand_refresh_providers_via_redis_handlerno longer apply freshly fetched cloud IP ranges to the in-memory cache before the store write that persists them. A store or Redis write failure was caught and logged, but the in-memoryip_rangeshad already been updated, sois_cloud_ipreported ranges that were never persisted, a silent divergence between what a single process believes and what other workers see through Redis. The write now happens first; on failure the provider falls through to the existing preserve-or-empty fallback, the same one already used for a fetch failure. Sync mirror regenerated.BehaviorTracker.usage_counts/return_patternsouter stores, keyed by endpoint id, are now bounded. Only the inner per-client store was LRU-bounded; the outer dict grew one entry per distinct endpoint id forever. It now evicts the least-recently-touched endpoint once it tracks 10,000, the same_lru_pop_or_createpattern already used for the inner store, dropping that endpoint's entire inner store on eviction.- The sync
BehaviorTracker.usage_counts/return_patternsin-memory stores had no lock, unlike their async counterparts (which never need one).get_recent_event_countiteratedusage_counts.values()whiletrack_endpoint_usage/track_return_patterninserted or LRU-evicted entries from another thread with no synchronisation; under a real WSGI thread pool this raisedRuntimeError: dictionary changed size during iteration(measured: 55 times across 6 writer and 2 reader threads in 3 seconds), silently dropped by the enricher's blanket exception handler.BehaviorTracker.__init__now builds athreading.Lockin the async source (a pattern already used elsewhere, e.g.core/checks/helpers.py's_suspicious_counts_lock) that guards every read and write of both stores; it carries through the sync mirror unchanged since a plainthreading.Lockneeds no async-to-sync translation, and costs one uncontended acquire per call in the async tree.ratelimit_handler.py's sync-only_redis_fail_open_warnedflag now flips under its existing_by_ip_locktoo, so the warn-once guarantee holds under threads. - An
X-Forwarded-Forentry carrying a port was discarded, falling back to the connecting peer for the whole header.1.2.3.4:5678and[2001:db8::1]:5678failedip_address()parsing outright, since neither_strip_ip_bracketsnor anything downstream stripped the port. Each comma-separated entry now has a trailing port stripped before parsing (_strip_forwarded_entry_port,guard_core/_utils/ip_extraction.py): a bracketed[v6]:portshape or an entry with exactly one colon has the port removed; a bare (unbracketed) IPv6 address, which always carries two or more colons, is never mistaken forhost:portand is left unchanged, as is an entry that looks likehost:portbut has a non-numeric or missing port, which still fails parsing through the existing malformed-entry path. Sync mirror regenerated. - guard-core no longer double-logs into a host's own root logger handlers.
setup_custom_loggingunconditionally attached a consoleStreamHandlerto theguard_corelogger and leftpropagate=True; a host that had already configured its own root logger handlers (the commonlogging.basicConfig()case) printed every guard security event twice, once formatted by guard's own handler and once via propagation through the host's. A setup-time check alone is not enough, since the ordering can go the other way: guard configures itself first while the root logger is still empty, and the host configures its own root handlers afterward, which reintroduces the same duplicate under the opposite ordering.setup_custom_loggingnow always attaches its console handler, but that handler carries alogging.Filterthat checkslogging.getLogger().handlersat the moment each record is emitted rather than at setup time: guard's console output yields to the host's root handlers whenever they exist, whichever was configured first, and the event propagates to the host's own handlers exactly once. Thecustom_log_filehandler carries no such filter and is attached in every case, unaffected, and level handling is unchanged. A host that wants guard's own formatted output (text or JSON) while running its own root handlers should setcustom_log_file, or attach its own handler directly to theguard_corelogger.
Security
- Uncontrolled recursion in the JSON body walk (CWE-674). The structural JSON-body scan recursed one Python call per nesting level with no depth bound; a
{"a":{"a":...}}body nested past Python's recursion limit raisedRecursionErrorout ofdetect_penetration_attempt, and just below that limit the enhanced-detection exception fallback silently swallowed the error and scanned the request as clean instead of failing secure. The walk is now an explicit-stack iteration with no recursion anywhere in guard-core's own JSON traversal. A newSecurityConfig.detection_max_json_depth(default32,1to1000) bounds the nesting depth walked structurally; a dict or list reached at that depth is serialized back to text and scanned as one value instead, bounded bydetection_max_content_length, and a one-time warning names the client IP._check_value_enhancedno longer treatsRecursionErroras a fallback case, it now re-raises into the pipeline's fail-secure handling instead of ever returning a silent clean verdict (GHSA-f6cf-jjhc-qp85). - Uncontrolled recursion in embedded-JSON detection for query, header and form values (CWE-674, same class as GHSA-f6cf-jjhc-qp85, different context).
_try_check_json_value(the check that looks for a JSON object embedded in a single query parameter, header, or form-field value) only caughtjson.JSONDecodeErroraroundjson.loads; a value holding around 1000 nested braces raisedRecursionErrorinstead, out ofdetect_penetration_attempt, before the surrounding fallback logic ever ran.RecursionErroris now caught in the same place and treated as "not embedded JSON": the value falls through to the ordinary pattern scan of its raw text instead of the structural walk, and the existingdetection_max_json_depthwarning fires once per request. - A declared
trusted_proxy_depththat over-counts the real proxy hops let a client rotate its resolved identity freely (GHSA-8xvm-856x-7hwp, claim 1 residual). Withtrusted_proxiesnon-empty,extract_client_ipselectedips[-trusted_proxy_depth]fromX-Forwarded-Forwith no check that the entries to its right were actually appended by trusted proxies; when the real chain had fewer hops than the declared depth, a client that supplied extra entries controlled which one landed at that position, and the 3.14.0 chain-warnings did not catch it because the selected entry is an ordinary public address, not itself a listed trusted proxy. guard-core now verifies that every entry to the right of the depth-selected one is itself a listed trusted proxy (canonicalised, so an IPv4-mapped or bracketed IPv6 spelling is recognised); if any is not, it logs one warning per process namingtrusted_proxy_depth, the count of untrusted entries, and the fix, and resolves the identity by walking the chain right to left, returning the first entry that is not a listed trusted proxy (the address the nearest genuine proxy actually appended). Nothing changes whentrusted_proxiesis empty, when the chain is shorter than the declared depth, or when every entry to the right of the selection is already a listed trusted proxy. - A body whose declared
Content-Lengthexceedsdetection_max_body_inspect_bytesis no longer silently skipped when the adapter supports a bounded read (GHSA-3hfx-8m47-5f9h residual)._read_capped_bodyreturnedNonefor the whole request body once the declared length exceeded the cap, so an attacker could evade body-based detection entirely just by declaring an oversizedContent-Length, even with an attack in the first few bytes. When the request implements the bounded reader (read_body_prefix), it now reads only the firstdetection_max_body_inspect_bytesbytes through it and scans that prefix, keeping the existing single-memory-bound guarantee (a JSON body that gets cut mid-structure falls back to the existing blob-text scan); when the request does not implement a bounded reader, the body is not read at all, the same skip as before this change, since reading it any other way would mean buffering the whole oversized body first. Either way a one-time warning names the cap, the client, and which of the two happened. A newSecurityConfig.detection_max_scan_chars(default65536,1024to262144) separately bounds the total characters handed to the pattern engine per request, at the same accounting pointdetection_max_scan_valuesuses; a handful of individually large values could previously cost as much CPU as many small ones without ever approaching the value-count cap (28 values x 9342 characters cost 1.81s CPU in enhanced mode). The budget is a leaky bucket, not a per-value truncation: a value already in progress when checked is always scanned in full even if that pushes the running total over the cap, so a single large legitimate value is never silently dropped; only a value that would start after the budget is already spent is skipped, with the same one-time warning.detect_penetration_attempt(request, config)also now configures the detection singleton fromconfigitself whenever it is unconfigured or was last configured from a different config object (compared by identity, so this is a no-op once already configured from the same object), instead of requiring the caller to callsus_patterns_handler.configure(config)first: a direct caller that never did (guard-core-mcp'scheck_payload, a PoC script, a test written against this function directly) previously ran the legacy per-pattern thread-pool dispatch, an order of magnitude slower than the enhanced path and, on some payloads, producing different detection results.
What's Changed
Full Changelog: 3.14.0...3.15.0