fix(accounts): honor stored return_to after subtype account creation (#2109)

* fix(accounts): honor stored return_to after subtype account creation

Closes #1766.

The savings-goals empty-state "Add an account" CTA passes ?return_to, which
StoreLocation captures into session[:return_to], but account-creation flows
didn't always consume it:

- AccountableResource#create honored a form-carried return_to but not the
  session value, so if the param wasn't threaded through the multi-step
  new-account flow the user still landed on the account page. Added a
  session[:return_to] fallback (the form param still wins).
- PropertiesController is a 3-step wizard (create → balances → address) that
  never threaded return_to as a form param, and its final redirect went
  straight to account_path. It now honors session[:return_to] on completion.

Rails blocks external-host redirects, so return_to can't open-redirect.
valuations#create uses redirect_back_or_to (referer-based) — different flow,
left as-is.

Tests: depository create prefers the form return_to and falls back to the
session value; property wizard completion honors the stored return_to.

* fix(accounts): block open-redirect via return_to; consume session value

Two AI-review findings on #2109:

- Open-redirect (codex): the property wizard's turbo_stream completion uses
  stream_redirect_to, which the client resolves with Turbo.visit — that
  full-navigates cross-origin, bypassing Rails' redirect host-guard. A crafted
  ?return_to=https://evil could walk the user off-site. Filter return_to at the
  StoreLocation choke point (store time) to internal absolute paths only, and
  sanitize the separate form-param channel, so an unsafe value can't reach
  redirect_to / stream_redirect_to.
- Stale session (coderabbit): session[:return_to] was read but never consumed.
  Consume it with delete at redirect time so it can't leak into a later flow.

Adds guard tests (external return_to falls back to the account page).

* fix(security): guard safe_return_to against non-String return_to

A crafted `?return_to[]=foo` makes params[:return_to] an Array, and
Array#match? doesn't exist, so safe_return_to raised NoMethodError
before the open-redirect hardening could reject it. Add an
is_a?(String) check as the first gate. Other CodeRabbit/Codex
return_to findings on this PR were already addressed (consume-side
re-validation + session.delete).
This commit is contained in:
Guillem Arias Fauste
2026-06-03 15:15:49 +02:00
committed by GitHub
parent 5abf9cb537
commit 74f811c334
5 changed files with 149 additions and 6 deletions

View File

@@ -188,4 +188,89 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest
assert draft_account.active?
assert_redirected_to account_path(draft_account)
end
test "address update on draft account honors stored return_to over the account page" do
draft_account = Account.create!(
family: @user.family,
name: "Draft Property RT",
accountable: Property.new,
status: "draft",
balance: 500000,
currency: "USD"
)
# The property wizard (create → balances → address) doesn't thread return_to
# as a form param, so StoreLocation's session value is the only carrier.
get new_account_path(return_to: transactions_path)
patch update_address_property_path(draft_account), params: {
property: {
address_attributes: {
line1: "789 Activate St",
locality: "New York",
region: "NY",
country: "US",
postal_code: "10001"
}
}
}
draft_account.reload
assert draft_account.active?
assert_redirected_to transactions_path
end
test "address update ignores an external stored return_to (open-redirect guard)" do
draft_account = Account.create!(
family: @user.family,
name: "Draft Property Evil",
accountable: Property.new,
status: "draft",
balance: 500000,
currency: "USD"
)
# A hostile ?return_to is rejected at store time, so the wizard falls back
# to the account page rather than stream-redirecting off-site.
get new_account_path(return_to: "https://evil.example/phish")
patch update_address_property_path(draft_account), params: {
property: {
address_attributes: {
line1: "1 Safe St", locality: "NYC", region: "NY", country: "US", postal_code: "10001"
}
}
}
draft_account.reload
assert draft_account.active?
assert_redirected_to account_path(draft_account)
end
test "address update tolerates a non-String stored return_to without raising" do
draft_account = Account.create!(
family: @user.family,
name: "Draft Property Array",
accountable: Property.new,
status: "draft",
balance: 500000,
currency: "USD"
)
# `?return_to[]=foo` makes params[:return_to] an Array; safe_return_to must
# reject it via the is_a?(String) guard instead of raising NoMethodError.
get new_account_path("return_to" => [ "/transactions" ])
patch update_address_property_path(draft_account), params: {
property: {
address_attributes: {
line1: "1 Safe St", locality: "NYC", region: "NY", country: "US", postal_code: "10001"
}
}
}
draft_account.reload
assert draft_account.active?
assert_redirected_to account_path(draft_account)
end
end