mirror of
https://github.com/we-promise/sure.git
synced 2026-09-06 15:14:19 +00:00
* 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>
211 lines
8.8 KiB
Ruby
211 lines
8.8 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class Rack::Attack
|
|
# Enable Rack::Attack only in production and staging (disable in test/development to avoid rate-limit flakiness)
|
|
enabled = Rails.env.production? || Rails.env.staging?
|
|
self.enabled = enabled
|
|
|
|
# Throttle requests to the OAuth token endpoint
|
|
throttle("oauth/token", limit: 10, period: 1.minute) do |request|
|
|
request.ip if request.path == "/oauth/token"
|
|
end
|
|
|
|
throttle("oauth/register", limit: 10, period: 1.minute) do |request|
|
|
request.ip if request.post? && request.path == "/register"
|
|
end
|
|
|
|
# Throttle unauthenticated WebAuthn ceremonies similarly to sign-in
|
|
# endpoints; registration remains behind normal application authentication.
|
|
# Covers both the MFA step-up and passwordless passkey sign-in.
|
|
throttle("mfa/webauthn", limit: 10, period: 1.minute) do |request|
|
|
if request.post? && request.path.in?(%w[
|
|
/mfa/webauthn_options
|
|
/mfa/verify_webauthn
|
|
/sessions/passkey
|
|
])
|
|
request.ip
|
|
end
|
|
end
|
|
|
|
# The passwordless challenge endpoint gets its own, looser budget: browsers
|
|
# that support conditional mediation call it automatically on every login
|
|
# page load, so sharing the limit above would let ordinary page views lock a
|
|
# shared NAT out of MFA. Minting a challenge touches no credential and
|
|
# reveals nothing, and the assertion it produces is still verified under the
|
|
# stricter limit.
|
|
throttle("passkey/options", limit: 30, period: 1.minute) do |request|
|
|
request.ip if request.post? && request.path == "/sessions/passkey_options"
|
|
end
|
|
|
|
# Throttle admin endpoints to prevent brute-force attacks
|
|
# More restrictive than general API limits since admin access is sensitive
|
|
throttle("admin/ip", limit: 10, period: 1.minute) do |request|
|
|
request.ip if request.path.start_with?("/admin/")
|
|
end
|
|
|
|
# --- Credential-guessing surfaces (security audit #1087, finding H4) ---
|
|
# Every endpoint in the app that checks a password, TOTP code, or backup
|
|
# code against a stored value (enumerated by grepping for
|
|
# User.authenticate_by/#authenticate/#verify_otp? across app/controllers —
|
|
# #verify_otp? covers both TOTP and backup codes internally, so there's no
|
|
# separate backup-code endpoint to add). Each is throttled by BOTH ip and
|
|
# a credential-identifying discriminator (email, or the MFA step-up's
|
|
# session-bound user id) so an attacker can't bypass by rotating IPs
|
|
# against one target, nor by spraying many emails from one IP — either
|
|
# throttle firing blocks the request, since Rack::Attack requires ALL
|
|
# matching throttles to pass.
|
|
#
|
|
# oidc_account/create_link and api/v1/auth/sso_link are password checks
|
|
# gating account linking, not sign-in — easy to miss by grepping routes.rb
|
|
# for "session"/"login"/"password" alone.
|
|
# request.params only exposes query/form parameters — Rack::Attack runs
|
|
# ahead of Rails' JSON parameter parsing, so JSON API clients (the
|
|
# documented format for api/v1/auth/login and .../sso_link) would otherwise
|
|
# bypass the email throttle entirely. Peek at the JSON body without
|
|
# consuming it, so the controller still gets a fresh, unread input stream.
|
|
json_request_email = ->(request) do
|
|
# Rack 3 no longer requires rack.input to be rewindable — bail out
|
|
# without reading if the server's input stream can't be rewound, rather
|
|
# than consuming a body the controller can never get back.
|
|
next nil unless request.media_type == "application/json" && request.body.respond_to?(:rewind)
|
|
|
|
body = request.body.read
|
|
request.body.rewind
|
|
payload = JSON.parse(body)
|
|
payload["email"] if payload.is_a?(Hash)
|
|
rescue JSON::ParserError, TypeError
|
|
nil
|
|
end
|
|
|
|
credential_guess_email = ->(request) {
|
|
email = request.params["email"].presence || json_request_email.call(request)
|
|
email.to_s.downcase.strip.presence
|
|
}
|
|
|
|
# None of the routes below are declared `format: false`, so Rails' default
|
|
# `(.:format)` segment means e.g. "/sessions.json" still reaches
|
|
# SessionsController#create even though request.path for that request is
|
|
# "/sessions.json". Exact string equality would silently skip every
|
|
# throttle in this section for a scripted attacker who appends any
|
|
# extension, while User.authenticate_by (etc.) still runs unthrottled.
|
|
# Match the optional format suffix explicitly instead.
|
|
credential_guess_path = ->(request, path) { request.path.match?(/\A#{Regexp.escape(path)}(?:\.[^.\/?]+)?\z/) }
|
|
|
|
throttle("logins/ip", limit: 10, period: 1.minute) do |request|
|
|
request.ip if request.post? && credential_guess_path.call(request, "/sessions")
|
|
end
|
|
|
|
throttle("logins/email", limit: 10, period: 1.minute) do |request|
|
|
credential_guess_email.call(request) if request.post? && credential_guess_path.call(request, "/sessions")
|
|
end
|
|
|
|
# MFA step-up has no email param — the pending user is looked up from
|
|
# session[:mfa_user_id], so that's the discriminator instead.
|
|
throttle("mfa/verify/ip", limit: 10, period: 1.minute) do |request|
|
|
request.ip if request.post? && credential_guess_path.call(request, "/mfa/verify")
|
|
end
|
|
|
|
throttle("mfa/verify/user", limit: 10, period: 1.minute) do |request|
|
|
if request.post? && credential_guess_path.call(request, "/mfa/verify")
|
|
request.session[:mfa_user_id]
|
|
end
|
|
end
|
|
|
|
throttle("password_resets/ip", limit: 10, period: 1.minute) do |request|
|
|
request.ip if request.post? && credential_guess_path.call(request, "/password_reset")
|
|
end
|
|
|
|
throttle("password_resets/email", limit: 10, period: 1.minute) do |request|
|
|
credential_guess_email.call(request) if request.post? && credential_guess_path.call(request, "/password_reset")
|
|
end
|
|
|
|
throttle("oidc_account_link/ip", limit: 10, period: 1.minute) do |request|
|
|
request.ip if request.post? && credential_guess_path.call(request, "/oidc_account/create_link")
|
|
end
|
|
|
|
throttle("oidc_account_link/email", limit: 10, period: 1.minute) do |request|
|
|
credential_guess_email.call(request) if request.post? && credential_guess_path.call(request, "/oidc_account/create_link")
|
|
end
|
|
|
|
throttle("api_login/ip", limit: 10, period: 1.minute) do |request|
|
|
request.ip if request.post? && credential_guess_path.call(request, "/api/v1/auth/login")
|
|
end
|
|
|
|
throttle("api_login/email", limit: 10, period: 1.minute) do |request|
|
|
credential_guess_email.call(request) if request.post? && credential_guess_path.call(request, "/api/v1/auth/login")
|
|
end
|
|
|
|
throttle("api_sso_link/ip", limit: 10, period: 1.minute) do |request|
|
|
request.ip if request.post? && credential_guess_path.call(request, "/api/v1/auth/sso_link")
|
|
end
|
|
|
|
throttle("api_sso_link/email", limit: 10, period: 1.minute) do |request|
|
|
credential_guess_email.call(request) if request.post? && credential_guess_path.call(request, "/api/v1/auth/sso_link")
|
|
end
|
|
|
|
# The background jobs console lives under /settings (so its polling GET
|
|
# isn't throttled), but its mutation is destructive and super-admin only —
|
|
# rate limit it independently.
|
|
throttle("background_jobs_console/ip", limit: 30, period: 1.minute) do |request|
|
|
request.ip if request.post? && request.path == "/settings/background_jobs/cancel"
|
|
end
|
|
|
|
# Determine limits based on self-hosted mode
|
|
self_hosted = Rails.application.config.app_mode.self_hosted?
|
|
|
|
# Throttle API requests per access token
|
|
throttle("api/requests", limit: self_hosted ? 10_000 : 100, period: 1.hour) do |request|
|
|
if request.path.start_with?("/api/")
|
|
# Extract access token from Authorization header
|
|
auth_header = request.get_header("HTTP_AUTHORIZATION")
|
|
if auth_header&.start_with?("Bearer ")
|
|
token = auth_header.delete_prefix("Bearer ").strip # pipelock:ignore
|
|
"api_token:#{Digest::SHA256.hexdigest(token)}"
|
|
else
|
|
# Fall back to IP-based limiting for unauthenticated requests
|
|
"api_ip:#{request.ip}"
|
|
end
|
|
end
|
|
end
|
|
|
|
# More permissive throttling for API requests by IP (for development/testing)
|
|
throttle("api/ip", limit: self_hosted ? 20_000 : 200, period: 1.hour) do |request|
|
|
request.ip if request.path.start_with?("/api/")
|
|
end
|
|
|
|
# Block requests that appear to be malicious
|
|
blocklist("block malicious requests") do |request|
|
|
# Block requests with suspicious user agents
|
|
suspicious_user_agents = [
|
|
/sqlmap/i,
|
|
/nmap/i,
|
|
/nikto/i,
|
|
/masscan/i
|
|
]
|
|
|
|
user_agent = request.user_agent
|
|
suspicious_user_agents.any? { |pattern| user_agent =~ pattern } if user_agent
|
|
end
|
|
|
|
# Configure response for throttled requests
|
|
self.throttled_responder = lambda do |request|
|
|
[
|
|
429, # status
|
|
{
|
|
"Content-Type" => "application/json",
|
|
"Retry-After" => "60"
|
|
},
|
|
[ { error: "Rate limit exceeded. Try again later." }.to_json ]
|
|
]
|
|
end
|
|
|
|
# Configure response for blocked requests
|
|
self.blocklisted_responder = lambda do |request|
|
|
[
|
|
403, # status
|
|
{ "Content-Type" => "application/json" },
|
|
[ { error: "Request blocked." }.to_json ]
|
|
]
|
|
end
|
|
end
|