Skip to content

v7.3.1

Choose a tag to compare

@rennf93 rennf93 released this 29 Jul 18:23
· 12 commits to master since this release
73ee2ce

Per-route checks now resolve through unbounded mount and router nesting (v7.3.1)

  • Security — Fixes GHSA-f2vm-w8gq-h378 (CWE-287, per-route authentication bypass, CVSS 7.4). SecurityMiddleware._match_route() capped its recursive walk of the route tree at a hardcoded depth of 8 and silently gave up beyond it, so an endpoint mounted 9 or more Mount levels deep never resolved, request.state.guard_route_id was never set, and guard-core's route-config resolver returned None — which every per-route check reads as "no policy to enforce". An endpoint protected by @require_auth was served to any unauthenticated client, and by the same path so were @rate_limit, @require_headers, @require_referrer, @custom_validation, @time_window, per-route IP restrictions, and request size / content-type limits. The arbitrary depth cap is replaced by cycle detection over the descent chain — the condition it was approximating — so nesting is now unbounded while a self-nesting router still terminates.
  • Security — The same bypass reached a second, more common topology: routers nested two or more levels deep via include_router, previously recorded in 7.2.2 as "deeply-nested prefixed routers remain a known limitation". FastAPI's _IncludedRouter keeps its sub-routes un-prefixed and applies the combined include prefix only in effective_candidates(), so descending into original_router.routes compared a request path like /r0/r1/secret against a route registered as /r1/secret and matched nothing. Resolution now descends through effective_candidates() where available — falling back to the previous attribute walk on FastAPI versions without it — and unwraps the _EffectiveRouteContext proxies it yields. Per-route decorators consequently fire at any include depth, and also on Mounts and plain Starlette Routes registered on an included router, which include_router(router, prefix=...) over router.mount(...) had likewise left unresolved.
  • AddedSecurityMiddleware now reports a failed route match by setting request.state.guard_route_unresolved, which is the fail-open half of the advisory: get_route_config() returns None both when a route carries no per-route config and when resolution failed, and every per-route check collapses those two states into "nothing to enforce". Skipping checks on an undecorated route is correct and unchanged; skipping them because resolution failed is not. With guard-core >= 3.7.0 and SecurityConfig.route_resolution_strict=True, an unresolved request is logged, emits a route_unresolved event, and is blocked with 500 instead of running the pipeline with no per-route config. It defaults to False because a failed match is indistinguishable from a request the app does not route, so enabling it also turns unserved paths into 500s rather than 404s. Older guard-core ignores the new state attribute.
  • Performance — Traversal is bounded by marking each route and sub-route collection as visited per request scope rather than by a fixed depth, so a graph reaching the same collection along many paths is walked once instead of exponentially often. An app with two overlapping sibling mounts per level resolves a non-matching 24-level path in 0.2 ms, against 99 s for a depth-capped walk without the marking. The one graph shape that still recurses without bound manufactures fresh sub-route objects on every access, which is not constructible through public FastAPI or Starlette APIs; it raises RecursionError and surfaces as 500 rather than as a silently unchecked route.
  • FixedSecurityConfig.global_behavior_rules never ran. _process_response() passed process_behavioral_rules to guard-core's response factory but never process_global_behavioral_rules, and the factory only evaluates global rules when that callback is supplied, so the branch was unreachable on every request. A rule such as return_pattern with threshold=2 and action="ban" served six matching requests without ever banning. The callback is now wired to BehavioralProcessor.process_global_return_rules. Global rules additionally need guard-core >= 3.7.0, which fixes the companion defect that left the behavioural tracker unresolved on decorator-only setups and made usage_monitor and return_monitor inert too.
  • Tests — Route resolution and end-to-end @require_auth enforcement are now covered at 12 nested Mount levels and at 12 nested prefixed routers, plus a Mount inside a prefixed included router, alongside the existing self-nesting-router termination test. The global-rule callback is asserted at the call site; the end-to-end banning behaviour it enables is covered in guard-core.

What's Changed

  • build(deps): bump actions/setup-python from 6 to 7 by @dependabot[bot] in #101
  • build(deps): bump actions/labeler from 6 to 7 by @dependabot[bot] in #102
  • ci: test the pull request's own code by @rennf93 in #103
  • ci: run CI when workflows change by @rennf93 in #104
  • fix(middleware): resolve routes through unbounded mount and router nesting (v7.3.1) by @rennf93 in #105

Full Changelog: 7.3.0...7.3.1