Repository navigation
v3.14.0
Post-3.13.0 hardening: identity and proxy-trust warnings, bounded in-memory stores, resilient Redis/GeoIP/Azure startup, telemetry secret redaction, and detection scan-cap and SSRF hardening (v3.14.0)
Special thanks to @RomanPszonka for the twelve reports and advisories behind this release and the support throughout. Thank you for making the Guard Core ecosystem better!
Highlights
- Breaking:
PerformanceMonitor.get_summary_stats/get_slow_patterns/get_problematic_patternsare now coroutines, anddetection_max_scan_valueshas a floor. All three reporting methods areasync defin the async tree and must now be awaited; theguard_core.syncmirror keeps them synchronous.SecurityConfig.detection_max_scan_valuesnow requires at least2(each named value costs two scan units, so1could never scan a value) (GHSA-3hfx-8m47-5f9h). - Missing-client requests rejected; new
unixtrusted-proxy token. A request with no client address (request.client_hostisNone) is now rejected instead of skipping the entire security pipeline; a new"unix"token intrusted_proxiesresolvesX-Forwarded-Forbehind Unix-socket deployments (GHSA-634g-4wr8-xwxv). - X-Forwarded-For chain warnings. guard-core now warns once when a forwarded-for chain cannot satisfy
trusted_proxy_depth, and once when the depth-selected entry is itself a listed trusted proxy; resolved identity is unchanged in both cases (GHSA-8xvm-856x-7hwp). - Prefix-0 trusted-proxy and empty-detection-category startup warnings.
SecurityConfignow warns at construction on a/0trusted-proxy network (0.0.0.0/0,::/0) and on an emptyenabled_detection_categorieswith detection enabled, two previously silent misconfigurations (#79). - Ban canonicalisation.
ban_ip,is_ip_banned, andunban_ipcanonicalise the address before storing, querying, or deleting it, closing a silent no-op when the same IP is banned and unbanned under different spellings (#81). - Redis-outage startup degrade. A Redis outage at startup no longer crashes the app;
redis_fail_open=Truedegrades to in-memory backends,redis_fail_open=Falsere-raises so the adapter returns a clean error (#76). - GeoIP last-known-good.
IPInfoManagerkeeps the last known good GeoIP database on a failed refresh or download instead of deleting it, and downloads to a temporary file that swaps in atomically (#78). - AzureCloud service tag. Azure cloud-IP fetch now loads the
AzureCloudservice tag by name instead of whichever tag sorts first (#77). - Rate-limit stores bounded;
Retry-Afteron 429s. In-memory rate-limit stores are now LRU-bounded at 10,000 IPs; every 429 response now carriesRetry-After(GHSA-g53w-gmp9-9ch3, #81). - Behavior-tracker stores bounded.
BehaviorTracker.usage_counts/return_patternsper-client stores are now LRU-bounded at 10,000 clients per key, the same pattern applied to the rate-limit stores above (GHSA-g53w-gmp9-9ch3). - Float anomaly statistics.
PerformanceMonitor's statistical-anomaly check replaces exact-Fraction arithmetic withmath.fsum-based float mean/variance, removing an unbounded per-value CPU tax (GHSA-grqq-qh92-hw79). - Per-request scan cap.
SecurityConfig.detection_max_scan_valuesbounds the number of request values scanned per request, replacing an unbounded scan an attacker could exhaust with padding (GHSA-3hfx-8m47-5f9h). - Telemetry secret redaction. The IPInfo token and Redis password no longer reach telemetry: the token moves to an
Authorizationheader, andRedisManager.initializestrips userinfo fromredis_urlbefore it reaches agent events (#80). - Header-name validation. Custom security-header names, including Redis-loaded ones, are now validated against RFC 9110 token grammar and rejected on CRLF or other non-token characters (#81).
- SSRF form coverage.
ssrfnow detects the IPv4-mapped IPv6 loopback bracket form and a trailing-dotlocalhost(#81).
Added
- New
SecurityConfig.detection_max_scan_valuesbounds the number of request values scanned per request, including JSON embedded within a single query-parameter, header, or body value. Default512. Once reached, remaining query-parameter, header, and body values are not scanned, and a one-timelogger.warningnames the client IP, replacing an unbounded per-request scan an attacker could exhaust with padding (GHSA-3hfx-8m47-5f9h).
Changed
PerformanceMonitor.get_summary_stats,get_slow_patterns, andget_problematic_patternsare now coroutines in the async tree. They snapshotrecent_metrics/pattern_statsunder the monitor's lock before reading, the same fix applied torecord_metric's statistical-anomaly check; a direct caller must nowawaitthem.SusPatternsManager.get_performance_stats, the only production caller, was alreadyasyncand its own call signature is unchanged. Theguard_core.syncmirror keeps all three as plain methods.
Fixed
- A request with no client address (
request.client_hostisNone, e.g. behind a Unix domain socket or a misbehaving ASGI adapter) is now rejected instead of skipping the entire security pipeline.fail_secure=True(the default) returns 403 with a one-time warning naming the cause and the fix;fail_secure=Falseruns the pipeline with identity"unknown", the same fallback already used elsewhere, with the same one-time warning. Excluded paths (health and readiness endpoints) are unaffected: they pass through before identity is resolved, exactly as before, so a Unix-socket deployment upgrading without"unix"intrusted_proxiesdoes not start failing orchestrator probes. A new"unix"token intrusted_proxiesmarks a peer-less connection as a trusted hop, soX-Forwarded-Forstill resolves the real client on Unix-socket deployments. Underfail_secure=False,check_ip_accessnow treats the"unknown"identity as no address available: the request is allowed unless a whitelist or a country allow-list is configured (whitelistorwhitelist_countries), and the blacklist,blocked_countries, and cloud-provider checks are skipped since none can match without an address;check_route_ip_accessapplies the same rule to a decorated route'sip_whitelist/whitelist_countries, so a route with any other restriction (or none at all) no longer 403s an"unknown"identity outright (GHSA-634g-4wr8-xwxv). - guard-core now warns once when an X-Forwarded-For chain cannot satisfy
trusted_proxy_depth, and once when the depth-selected entry is itself a listed trusted proxy. Both are misconfiguration signals; resolved identity is unchanged in both cases (a chain shorter than the depth still falls back to the connecting peer, exactly as before).trusted_proxy_depthsemantics are unchanged: it is not reinterpreted as a ceiling (GHSA-8xvm-856x-7hwp). SecurityConfignow warns at construction on two silent-gap configurations. Atrusted_proxiesentry that is a/0network (0.0.0.0/0,::/0) trusts every peer to setX-Forwarded-For; an emptyenabled_detection_categorieswithenable_penetration_detection=Trueruns detection that can never match anything. Both previously succeeded with no signal; both now log aWARNINGand still succeed (#79).ban_ip,is_ip_bannedandunban_ipnow canonicalise the address before storing, querying or deleting it.2001:DB8::1,2001:db8::1and the fully expanded form (and IPv4-mapped IPv6 like::ffff:1.2.3.4vs1.2.3.4) were previously three different keys on both the in-memory cache and Redis, sounban_ipcalled with a different spelling than the one used to ban was a silent no-op. CIDR entries are unaffected (_canonicalize_ipreturns non-address strings unchanged) (#81).HandlerInitializer.initialize_redis_handlersno longer lets a Redis outage at startup crash the app.GuardRedisErrorfromRedisManager.initialize()is now caught and logged;redis_fail_open=Truedegrades to the in-memory backends used when Redis is disabled instead of wiringip_ban_manager, the rate limiter, andsus_patterns_handlerto Redis, andredis_fail_open=False(default) re-raises so the adapter returns a clean error instead of an unhandled exception.sus_patterns_handler.initialize_redisandIPInfoManager.initialize/_download_databasenow also catch a Redis failure on their own eager cache reads and writes, logging a warning and keeping the handler wired to Redis for later retries instead of letting the exception escape past the fail-open degrade (#76).IPInfoManagerkeeps the last known good GeoIP database on a failed refresh or download.initialize()andrefresh()no longer delete the on-disk.mmdbfile or clearreaderwhen a download fails;maxminddb.open_databaseis now guarded at every call site, and only an unreadable (corrupt) file is removed.refresh()no longer clearsreaderbefore attempting the download, and only swaps in the new reader once the fresh database opens successfully (#78).- Azure cloud-IP fetch now loads the
AzureCloudservice tag instead of whichever tag sorts first. The Microsoft ServiceTags document lists many tags per download;fetch_azure_ip_rangespreviously tookvalues[0], which is not guaranteed to beAzureCloud. It now selects the entry namedAzureCloudand logs an error and returns an empty set if that tag is missing (#77). - In-memory rate-limit stores are now bounded, and 429 responses carry
Retry-After._by_ip_request_timestamps,_by_ip_autoban_counts, andRateLimitManager.request_timestampsgrew one entry per distinct IP forever; all three now evict the least-recently-touched key once they track 10,000 IPs, the same LRU-bound pattern_increment_suspicious_countsalready uses._handle_rate_limit_exceedednow setsRetry-Afterto the effective rate-limit window on every 429 it returns, in-memory and Redis-backed alike. Sync mirror hand-updated (GHSA-g53w-gmp9-9ch3, #81). BehaviorTracker.usage_counts/return_patternsper-client in-memory stores are now bounded. Each outer key (endpoint id, orendpoint:patternfor return-pattern rules) now evicts its least-recently-touched client IP once it tracks 10,000 clients, the same LRU-bound pattern applied to the rate-limit stores above. The Redis-backed path andget_recent_event_countare unchanged (GHSA-g53w-gmp9-9ch3).PerformanceMonitor's statistical-anomaly check no longer uses exact-Fraction arithmetic.detect_statistical_anomalycalledstatistics.mean/statistics.stdevon everyrecord_metriconce a pattern's window warmed tomin_samples_for_anomaly, an unbounded per-value CPU tax on all enhanced-mode traffic (CWE-400); replaced withmath.fsum-based float mean/sample-variance, producing the same anomaly decisions. Sync mirror regenerated.- IPInfo token and Redis password no longer reach telemetry.
IPInfoManager._download_databasesends the IPInfo token as anAuthorization: Bearerheader instead of a?token=query-string parameter, and_send_geo_event'sreasonno longer embeds a raw exception'sstr()(which could carry the request URL viaaiohttp.ClientResponseError/requests.HTTPError); reduced to the exception class name plus HTTP status when available.RedisManager.initializestrips userinfo fromredis_urlbefore it reaches theredis_connection/redis_erroragent events, keeping onlyscheme://host[:port]. Async and sync mirrors updated identically (issue #80). - Custom security-header names are now validated, including Redis-loaded ones.
SecurityHeadersConfigMixin._validate_header_nameenforces the RFC 9110 token grammar and rejects a name containing CRLF or any other non-token character, wired into_add_custom_headers.SecurityHeadersCacheMixin._load_cached_confignow runs every cachedcustom_headersname and value through the same two validators and discards the entry (logs a warning, keeps the prior configuration) instead of applying an invalid cached header. Async and sync mirrors updated identically (issue #81). ssrfdetects the IPv4-mapped IPv6 loopback bracket form and a trailing-dotlocalhost.http://[::ffff:127.0.0.1]/andhttp://localhost./now match thessrfcategory.http://[::ffff:8.8.8.8]/(a public address in the same bracket shape) and the existinglocalhost.example.com/notlocalhost.iolookalikes stay unflagged. Sync mirror updated in lockstep (issue #81).guard_core.handlers.behavior_handlerandguard_core.sync.handlers.behavior_handlercan now be imported standalone. The behavior-tracker bound-store fix above pulled_lru_pop_or_createin fromguard_core.core.checks.helpers, which importsguard_core.core, whose package init reaches back throughguard_core.decorators.behavioraltoBehaviorRulein the still-initializingbehavior_handlermodule; a fresh interpreter raisedImportError: cannot import name 'BehaviorRule' from partially initialized module 'guard_core.handlers.behavior_handler' (most likely due to a circular import), breakingimport guardin fastapi-guard._lru_pop_or_createnow lives in a dependency-free leaf module,guard_core._utils.lru_store;helpers.pyre-exports it andbehavior_handler.py/ratelimit_handler.pyimport it directly, so neither pulls inguard_core.coreat module load. Sync mirror regenerated; the hand-maintainedguard_core/sync/handlers/ratelimit_handler.pyupdated to match.RedisManager.initializeno longer abandons a live client when re-initialising. Every re-initialised manager (each middleware built in tests, any app that constructs the manager twice) previously overwroteself._rediswithout closing the client already there, leaking a redis-py connection pool that GC later finalised withResourceWarning: unclosed Connection; theenable_redis=Falsebranch and the connection-failure branch dropped their client the same way.initializeandclosenow route through a shared_discard_clientthat closes the existing client before it is replaced or dropped, so the manager owns at most one live client at a time; a close failure is logged and swallowed rather than blocking re-initialisation. Sync mirror regenerated.PerformanceMonitor.record_metricno longer runs its statistical-anomaly check outside the lock._check_anomaliesiterated a pattern'srecent_timesdeque (math.fsumplus a generator) after the lock guarding appends to that same deque had already been released; a concurrentrecord_metriccall under real OS threads (the sync tree, e.g. gunicorn--threads) could append mid-iteration and raiseRuntimeError: deque mutated during iteration, dropping detection to the fallback scan. The statistical-anomaly check now runs inside the same lock as the append that feeds it; anomaly emission (agent send, cooldown) stays outside the lock. Sync mirror regenerated (PR #82).- The enhanced-detection exception fallback no longer runs an unbounded regex scan.
_fallback_pattern_checkcalledpattern.search()directly on every compiled pattern with no scan-window bound and no timeout, so any exception in the enhanced path (including thePerformanceMonitorrace above) silently re-exposed the quadratic built-in patterns GHSA-r7hm-rjvg-xx7j bounded. The fallback now dispatches each pattern throughSusPatternsManager._check_regex_pattern, the same bounded matchers (scan-window finders and the executor-timeout path) the enhanced scan uses, so aRecursionErroron one pattern is still logged and skipped but every scan stays bounded. Async and sync mirrors updated identically (GHSA-r7hm-rjvg-xx7j). PerformanceMonitor.get_summary_stats/get_slow_patterns/get_problematic_patternsno longer iteraterecent_metrics/pattern_statsoutside the lock.get_summary_statsiterated therecent_metricsdeque three times, andget_slow_patterns/get_problematic_patternsiterated thepattern_statsdict, both unlocked whilerecord_metricappends torecent_metricsand inserts/evictspattern_statskeys under the lock; reachable through the publicSusPatternsManager.get_performance_stats(), this raised the sameRuntimeError: deque mutated during iterationclass as therecord_metricrace above (and, forpattern_stats,RuntimeError: dictionary changed size during iteration). All three now snapshot the collection they read underself._lockbefore handing it to the (unlocked, pure) reporting helpers;get_pattern_reportwas audited and left unlocked, since it only does a single dict lookup plus scalar reads, never an iteration. Async and sync mirrors updated identically.
What's Changed
Full Changelog: 3.13.0...3.14.0