Skip to content

Fix cascaded routes leaking routing args into the next matched route - #2824

Merged
dblock merged 1 commit into
masterfrom
fix-router-cascade-routing-args
Jul 27, 2026
Merged

Fix cascaded routes leaking routing args into the next matched route#2824
dblock merged 1 commit into
masterfrom
fix-router-cascade-routing-args

Conversation

@ericproulx

Copy link
Copy Markdown
Contributor

Problem

Grape::Router#process_route seeds the routing args once and then merges every subsequent attempt's captures into the same Hash:

env[Grape::Env::GRAPE_ROUTING_ARGS] ||= { route_info: route }
env[Grape::Env::GRAPE_ROUTING_ARGS].merge!(route_params) if route_params.present?

When a matched route responds with X-Cascade: pass (the canonical case: header/accept-version versioning with cascade: true), #rotation (and the '*'-method fallback in #transaction) tries the next candidate route — but process_route runs again on an env whose routing args were already populated by the failed attempt. Because of the ||=, two things go wrong for the route that ultimately serves the request:

  • :route_info still points at the first (cascaded) route. The route helper inside the endpoint returns the wrong route, and ErrorFormatter::Base#present resolves http_codes entity presenters from the wrong route's metadata.
  • The failed attempt's path captures merge into params. Any capture name the winning route doesn't declare survives the merge and shows up in the endpoint's params.

Reproduction

v2 = Class.new(Grape::API) do
  version 'v2', using: :header, vendor: 'grape', cascade: true
  get ':id' { { from: 'v2' } }
end
v1 = Class.new(Grape::API) do
  format :json
  version 'v1', using: :header, vendor: 'grape'
  get ':name' { { origin: route.origin, params: params.to_h } }
end
app = Class.new(Grape::API) { mount v2; mount v1 }

GET /123 with Accept: application/vnd.grape-v1+json — the v2 route matches first, its versioner cascades, and the v1 endpoint then responds with:

{"origin":"/:id","params":{"id":"123","name":"123"}}

route.origin belongs to the v2 route it never ran, and params[:id] is a capture the v1 route never declared. Expected (and after this fix):

{"origin":"/:name","params":{"name":"123"}}

Fix

Build a fresh { route_info: route }.merge(route_params) on every process_route call. The router is the only writer of env['grape.routing_args'] (readers: Grape::Request#make_params, the route helper, and ErrorFormatter::Base), so resetting per attempt is safe — including for the greedy OPTIONS/405 helper routes, whose params_for yields no captures and which previously ran with an unset or unrelated args Hash anyway.

This behavior predates the recent router refactors — the ||=/seed-once shape goes back through #2754, #2689 and #2636 to the original make_routing_args(default_args || { route_info: route }).

🤖 Generated with Claude Code

process_route seeded env['grape.routing_args'] once (||=) and merged each
attempt's path captures in place. When a matched route cascaded
(X-Cascade pass), the next candidate tried by rotation/transaction still
saw the previous attempt's :route_info (so the `route` helper returned
the wrong route) and its path captures leaked into params.

Build a fresh { route_info: }.merge(route_params) per attempt instead;
the router is the only writer of this env key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ericproulx
ericproulx force-pushed the fix-router-cascade-routing-args branch from a55b683 to 658e0d7 Compare July 26, 2026 19:31
@github-actions

github-actions Bot commented Jul 26, 2026

Copy link
Copy Markdown

Danger Report

No issues found.

View run

@ericproulx
ericproulx requested a review from dblock July 26, 2026 19:34
@dblock
dblock merged commit 0a08e97 into master Jul 27, 2026
69 checks passed
@ericproulx
ericproulx deleted the fix-router-cascade-routing-args branch July 27, 2026 05:43
ericproulx added a commit that referenced this pull request Jul 27, 2026
#2824 made process_route build a fresh routing-args Hash per attempt, so
a candidate tried after a cascading match (X-Cascade pass) could not
observe the previous attempt's :route_info or path captures. #2829 was
squash-merged from a branch cut before #2824 landed, so its squashed diff
replayed the stale router.rb over the fix and dropped the regression spec
along with it. Git reported no conflict, and #2824's CHANGELOG entry
survived, so the tree claims a fix it no longer has.

Symptoms are back on master: with a cascading v2 and a serving v1, the
`route` helper returns v2's route (origin "/:id" instead of "/:name") and
v2's capture leaks into params as {"id"=>"123", "name"=>"123"} -- an
endpoint sees a param it never declared.

Restore the fix and its spec verbatim. The router is the only writer of
env['grape.routing_args'], so the unconditional assignment is safe.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants