Files
sure/config/initializers
78228e68dd security: throttle every credential-guessing endpoint, fix duplicate Rack::Attack middleware (#3263)
* security: throttle every credential-guessing endpoint, fix duplicate Rack::Attack middleware

Follow-up on #1087 (Findings H4, M7). PR 4 of the 6-PR series.

Enumerated every endpoint that checks a password, TOTP code, or backup
code (grepped for User.authenticate_by/#authenticate/#verify_otp? across
app/controllers, not just the ones named in the issue) — six in total,
none previously throttled:

- POST /sessions (SessionsController#create) — web login
- POST /mfa/verify (MfaController#verify_code) — TOTP + backup codes
  (#verify_otp? handles both internally, so no separate endpoint to add)
- POST /password_reset (PasswordResetsController#create) — also M7
- POST /api/v1/auth/login (Api::V1::AuthController#login) — mobile/API
- POST /oidc_account/create_link (OidcAccountsController#create_link) —
  password check gating SSO-identity linking, not sign-in; easy to miss
  grepping routes.rb for "session"/"login"
- POST /api/v1/auth/sso_link (Api::V1::AuthController#sso_link) — same
  as above for the mobile app

Each gets two throttles (ip AND normalized email, or ip AND the MFA
step-up's session-bound user id where there's no email param) so an
attacker can't bypass by rotating IPs against one target, nor by
spraying many emails from one IP — Rack::Attack requires every matching
throttle to pass. limit: 10/minute, matching the existing oauth/token
and admin/ip throttles already in this file.

Also fixed a latent, unrelated-but-adjacent bug found while confirming
these throttles would actually enforce the limits documented in their
own comments: config/application.rb had an explicit `config.middleware.use
Rack::Attack` alongside the gem's own Railtie doing the same thing (`bin/rails
middleware` listed it twice) — every throttle's counter was incrementing
twice per request, so all of them, old and new, were silently firing at
half their documented limit. Removed the redundant explicit registration.

Race-condition check (per standing instruction): Rack::Attack's counter
increments are atomic within its cache store, so concurrent requests at
the threshold don't undercount. No new race introduced.

New tests in test/integration/rack_attack_test.rb:
- Registration checks for all 6 new throttle keys (existing convention
  in this file).
- Direct block-level tests for the discriminator logic (right path
  matched, right value extracted, blank/missing input produces nil
  rather than a bogus key) — Rack::Attack's cache backs onto Rails.cache,
  which is :null_store in the test environment, so no amount of request
  volume in a normal integration test can ever actually trip a throttle
  here; calling the registered block directly against a constructed
  Rack::Attack::Request is what makes the assertions meaningful instead
  of just checking string keys exist.
- Regression test asserting Rack::Attack appears exactly once in the
  middleware stack.

Verified against the NAS sure_test_web container: full restart, bin/rails
test (8/8 rack_attack tests green; ran the full test/integration suite
plus sessions/mfa/password_resets/api-auth/oidc_accounts controller tests
too — 6 pre-existing failures, confirmed identical on the unmodified
baseline before concluding they're the known WebAuthn-RP-ID-mismatch and
AI-disabled environmental categories, not a regression), bin/rubocop,
bin/brakeman. Also did a live demonstration against the running container
(which runs RAILS_ENV=production, where Rack::Attack is actually enabled):
12 rapid POSTs to /sessions with bad credentials — requests 1-10 got 422,
11 and 12 got 429, exactly matching limit: 10. Container restored to its
original state and restarted afterward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* security: extract email from JSON bodies for credential-guess throttles

Rack::Attack runs before Rails' JSON parameter parsing, so request.params
only exposed query/form fields. The documented api/v1/auth/login and
.../sso_link JSON format bypassed the per-email throttle entirely,
letting an attacker rotate IPs against one target's account.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* security: guard JSON email peek against non-rewindable input and non-object payloads

Rack 3 no longer requires rack.input to be rewindable, and a bare
JSON.parse(body)["email"] raises NoMethodError on valid non-Hash JSON
(null, arrays, scalars) — either would 500 the request instead of just
skipping the email throttle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* security: assert non-rewindable JSON bodies stay readable by the controller

Only checking that the throttle discriminator returned nil left a gap: an
implementation that read the body and then discarded the result on error
would pass the same assertion while leaving the controller with an
exhausted stream.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* security: close credential-guessing throttle bypass via format-suffixed paths

request.path == "/sessions" (etc.) never matched "/sessions.json", which
Rails still routes to the same controller action since none of these
routes are declared format: false. Match the optional format suffix
explicitly instead, per jjmata's review on PR #3263.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* security: match Rails' actual format-segment charset in credential_guess_path

\w excludes hyphens, but Rails' default (.:format) segment matches
[^./?]+, which does include them — e.g. "/api/v1/auth/login.rate-limit"
still routed and bypassed the throttle. Match the real charset instead,
per CodeRabbit's follow-up on PR #3263.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Gerald <248542187+gfr-free@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 07:48:58 +02:00
..