diff --git a/.env.test b/.env.test index ec37bdcf..2246f4f3 100644 --- a/.env.test +++ b/.env.test @@ -14,6 +14,7 @@ PRIMO_SCOPE=cdi PRIMO_TAB=all PRIMO_VID=01MIT_INST:MIT PRIMO_NDE_VID=01MIT_INST:NDE +RACK_ATTACK_VERBOSE_LOGGING=false RESULTS_PER_PAGE=20 SYNDETICS_PRIMO_URL=https://syndetics.com/index.php?client=primo TACOS_HOST=FAKE_TACOS_HOST diff --git a/README.md b/README.md index 9a9cd5d4..8c66daf2 100644 --- a/README.md +++ b/README.md @@ -136,8 +136,14 @@ may have unexpected consequences if applied to other TIMDEX UI apps. - `PRIMO_TIMEOUT`: The number of seconds before a Primo request times out (default 6). - `REQUESTS_PER_PERIOD` - number of requests that can be made for general throttles per `REQUEST_PERIOD` - `REQUEST_PERIOD` - time in minutes used along with `REQUESTS_PER_PERIOD` +- `RESULTS_GLOBAL_LIMIT_PER_SEC` - maximum requests per second to `/results` and `/record` endpoints across all **non-safelisted** IPs (default 30). This protects against distributed botnet volume attacks by limiting total throughput regardless of source IP. When exceeded, requests are redirected to Turnstile. Must be used alongside per-IP throttling. +- `RESULTS_THROTTLE_LIMIT` - number of requests to `/results` and `/record` endpoints per `RESULTS_THROTTLE_PERIOD` (default 10). This is much stricter than the general throttle to defend against distributed botnet attacks. When this limit is exceeded, requests are redirected to Turnstile for challenge verification rather than returning a hard 429 error, allowing legitimate users to prove they're human. +- `RESULTS_THROTTLE_PERIOD` - time in minutes for `/results` and `/record` endpoint throttle (default 1 minute). Throttled requests are redirected to Turnstile for verification. +- `TURNSTILE_GRACE_PERIOD` - time in minutes that an IP is whitelisted from throttling after successfully passing Turnstile verification (default 15 minutes). This prevents users from being re-challenged repeatedly. +- `RACK_ATTACK_VERBOSE_LOGGING` - Set to `false` to disable detailed Rack::Attack throttle event logging to stdout (default `true`). Useful for reducing test output noise while still maintaining throttle functionality. +- `BLOCKED_USER_AGENTS` - comma-separated list of user agent strings to hard-block with 403 Forbidden responses (bypasses throttling; much cheaper). Default blocks `Sogou web spider` which was responsible for 76.94k spoofed attack requests from non-Chinese IPs. Example: `"Sogou web spider,BadBot/2.0"` - `REDIRECT_REQUESTS_PER_PERIOD`- number of requests that can be made that the query string starts with our legacy redirect parameter to throttle per `REQUEST_PERIOD` -- `REDIRECT_REQUEST_PERIOD`- time in minutes used along with `REDIRECT_REQUEST_PERIOD` +- `REDIRECT_REQUEST_PERIOD`- time in minutes used along with `REDIRECT_REQUESTS_PER_PERIOD` - `RESULTS_PER_PAGE`: The number of results to display per page. Use an even number to avoid peculiarities. Defaults to 20 if unset. - `ROBOTS_ENV`: Determines which version of `robots.txt` is used. This is read by the Robots controller. Any value other than `production` results in the non-production version being used. - `SCOUT_AUTO_INSTRUMENTS`: default is `false`. Recommended setting is `true` unless we add manual instrumentation in the future. diff --git a/app/controllers/turnstile_controller.rb b/app/controllers/turnstile_controller.rb index dc594da3..25df739a 100644 --- a/app/controllers/turnstile_controller.rb +++ b/app/controllers/turnstile_controller.rb @@ -7,8 +7,49 @@ def show @return_to = params[:return_to].presence || root_path end + # Marks the user as having passed Turnstile verification by setting both a session flag + # and a plain cookie with grace period. + # + # Two different storage mechanisms are used for different purposes: + # + # 1. session[:passed_turnstile] = true + # - Server-side session state (for view/controller logic compatibility) + # - Available to Rails controllers and views + # + # 2. cookies[:turnstile_verified_at] (new, primary) + # - Plain cookie containing a signed expiration timestamp (via Rails message_verifier) + # - Checked by Rack::Attack middleware *before* request reaches Rails + # - Middleware reads raw cookie values; Rails encrypted/signed cookies are unreadable, + # but message_verifier produces a self-contained signed string readable anywhere + # - Survives Redis eviction during attacks (stored on client, not in server cache) + # - Enables grace period: Rack Attack skips throttling if signature is valid and not expired + # - Signature prevents clients from forging a far-future timestamp to bypass throttles + # + # Why both? Rack Attack is middleware that runs before Rails session initialization. + # Without the plain cookie readable by middleware, every post-Turnstile request would still + # hit throttles and be challenged again, creating an infinite loop. The cookie signals to + # the middleware layer: "This IP recently verified; skip throttling until [timestamp]". def verify session[:passed_turnstile] = true + + # Set a signed cookie to skip Rack Attack throttling for this IP. + # The cookie contains a signed expiration timestamp that Rack::Attack middleware can verify. + # Signing prevents clients from forging a far-future timestamp to bypass throttles. + # Duration is controlled by TURNSTILE_GRACE_PERIOD (minutes; default 15). + # Survives Redis eviction during attacks (stored on client, not server cache). + # Clamp to positive integer to prevent accidental misconfiguration (e.g., "" or "0" becomes 0). + grace_period_minutes = [ENV.fetch('TURNSTILE_GRACE_PERIOD', 15).to_i, 1].max + expiration_time = Time.current + grace_period_minutes.minutes + signed_value = Rails.application.message_verifier(:turnstile_grace).generate(expiration_time.to_i) + + cookies[:turnstile_verified_at] = { + value: signed_value, + expires: expiration_time, + httponly: true, + secure: Rails.env.production?, + same_site: :lax + } + redirect_to safe_return_path end diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index 2282d3bd..ff1cd51f 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -17,6 +17,29 @@ class Rack::Attack # This also affects bot_challenge_page logic which uses rack_attack under the hood Rack::Attack.safelist_ip("18.0.0.0/11") + ### Blocklist Suspicious User Agents ### + + # Hard-block requests with user agents commonly associated with botnets or spoofed crawlers. + # These are immediately rejected with a 403 Forbidden response (much cheaper than throttling). + # + # Configure via BLOCKED_USER_AGENTS env var (comma-separated list). + # Example: "Sogou web spider,BadBot/2.0" + # + # Default includes "Sogou web spider" which was responsible for 76.94k attack requests + # originating from non-Chinese IPs with spoofed user agents. + blocked_agents = ENV.fetch('BLOCKED_USER_AGENTS', 'Sogou web spider') + .split(',') + .map(&:strip) + .reject(&:blank?) + + Rack::Attack.blocklist('user_agent/blocked') do |req| + is_blocked = blocked_agents.any? { |agent| req.user_agent&.include?(agent) } + if is_blocked + Rails.logger.warn("BLOCKED_USER_AGENT: #{req.user_agent.inspect} | IP: #{req.ip} | Path: #{req.path.inspect}") + end + is_blocked + end + ### Throttle Spammy Clients ### # If any single client IP is making tons of requests, then they're @@ -27,22 +50,88 @@ class Rack::Attack # counted by rack-attack and this throttle may be activated too # quickly. If so, enable the condition to exclude them from tracking. + # Global rate limit for /results and /record endpoints (excluding any Rack::Attack safelisted IPs) + # to protect against distributed volume attacks. Per-IP throttling can be bypassed by rotating + # through many IPs; this shared counter caps total throughput for all non-safelisted traffic. + # + # However, after a user passes Turnstile verification, we skip throttling for the grace period + # (default 15 minutes) to avoid repeated challenges during normal usage. + # Grace period is verified via a cookie set by the Turnstile controller. + # + # Default: 30 requests per second across all non-safelisted IPs + throttle('results/global', + limit: (ENV.fetch('RESULTS_GLOBAL_LIMIT_PER_SEC', 30)).to_i, + period: 1.second) do |req| + # Only apply to /results and /record endpoints + next nil unless req.path.start_with?('/results') || req.path.start_with?('/record') + + # Skip throttling if this IP recently passed Turnstile verification. + # Grace period is stored in a signed cookie that survives Redis eviction. + # The signature prevents clients from forging a far-future timestamp to bypass throttles. + cookie_value = req.cookies['turnstile_verified_at'] + if cookie_value.present? + begin + expiration_timestamp = Rails.application.message_verifier(:turnstile_grace).verify(cookie_value) + next nil if expiration_timestamp > Time.current.to_i + rescue ActiveSupport::MessageVerifier::InvalidSignature + # Tampered or invalid cookie — proceed with throttling + end + end + + # Use a constant key so this is a true global limit, not per-IP + 'results' + end + + # Throttle /results and /record requests more aggressively (default is 10 requests per minute) + # /results and /record endpoints are expensive and are common targets for botnet + # attacks using distributed IPs. This throttle is much stricter than the general + # throttle to defend against distributed bot attacks that stay under per-IP limits + # by rotating through many IPs. + # + # However, after a user passes Turnstile verification, we skip throttling for the grace period + # (default 15 minutes) to avoid repeated challenges during normal usage. + # Grace period is verified via a cookie set by the Turnstile controller. + # + # Key: "rack::attack:#{Time.now.to_i/:period}:req/ip/results:#{req.ip}" + throttle('req/ip/results', + limit: (ENV.fetch('RESULTS_THROTTLE_LIMIT', 10)).to_i, + period: (ENV.fetch('RESULTS_THROTTLE_PERIOD', 1)).to_i.minutes) do |req| + # Only apply to /results and /record endpoints + next nil unless req.path.start_with?('/results') || req.path.start_with?('/record') + + # Skip throttling if this IP recently passed Turnstile verification. + # Grace period is stored in a signed cookie that survives Redis eviction. + # The signature prevents clients from forging a far-future timestamp to bypass throttles. + cookie_value = req.cookies['turnstile_verified_at'] + if cookie_value.present? + begin + expiration_timestamp = Rails.application.message_verifier(:turnstile_grace).verify(cookie_value) + next nil if expiration_timestamp > Time.current.to_i + rescue ActiveSupport::MessageVerifier::InvalidSignature + # Tampered or invalid cookie — proceed with throttling + end + end + + req.ip + end + # Throttle all requests by IP (default is 100 requests per 10 minutes) + # Excludes /assets and /turnstile paths (users need to access Turnstile to verify and bypass throttles) # # Key: "rack::attack:#{Time.now.to_i/:period}:req/ip:#{req.ip}" throttle('req/ip', - limit: (ENV.fetch('REQUESTS_PER_PERIOD') { 100 }).to_i, - period: (ENV.fetch('REQUEST_PERIOD') { 10 }).to_i.minutes) do |req| - # don't include assets as requests - req.ip unless req.path.start_with?('/assets') + limit: (ENV.fetch('REQUESTS_PER_PERIOD', 100)).to_i, + period: (ENV.fetch('REQUEST_PERIOD', 10)).to_i.minutes) do |req| + # don't include assets or turnstile verification paths as requests + req.ip unless req.path.start_with?('/assets') || req.path.start_with?('/turnstile') end # Throttle redirects by IP (default is 5 per 10 minutes) # # Key: "rack::attack:#{Time.now.to_i/:period}:req/ip/redirects:#{req.ip}" throttle('req/ip/redirects', - limit: (ENV.fetch('REDIRECT_REQUESTS_PER_PERIOD') { 5 }).to_i, - period: (ENV.fetch('REDIRECT_REQUEST_PERIOD') { 10 }).to_i.minutes) do |req| + limit: (ENV.fetch('REDIRECT_REQUESTS_PER_PERIOD', 5)).to_i, + period: (ENV.fetch('REDIRECT_REQUEST_PERIOD', 10)).to_i.minutes) do |req| req.ip if req.query_string.start_with?('geoweb-redirect') end @@ -81,17 +170,40 @@ class Rack::Attack ### Custom Throttle Response ### - # By default, Rack::Attack returns an HTTP 429 for throttled responses, - # which is just fine. + # Redirect /results and /record throttles to Turnstile challenge instead of 429. + # This allows real users to solve a CAPTCHA and continue, rather than getting + # hard-blocked. This is more user-friendly for tuning since we can't perfectly + # distinguish bots from heavy legitimate usage. # - # If you want to return 503 so that the attacker might be fooled into - # believing that they've successfully broken your app (or you just want to - # customize the response), then uncomment these lines. - # self.throttled_response = lambda do |env| - # [ 503, # status - # {}, # headers - # ['']] # body - # end + # IMPORTANT: Only redirect if the matched throttle is one that has a grace period cache check + # (results/global or req/ip/results). Other throttles (req/ip, etc.) don't have grace period + # exemptions, so redirecting would create an infinite loop. + # + # For throttles without grace period support, return 429 instead. + self.throttled_responder = lambda do |env| + # Handle both Hash env and Rack::Request objects + env_hash = env.is_a?(Hash) ? env : env.env + request = env.is_a?(Hash) ? Rack::Request.new(env) : env + matched_throttle = env_hash['rack.attack.matched'] + + # Log all throttled requests to understand traffic patterns + Rails.logger.warn("THROTTLED_REQUEST: UA=#{request.user_agent.inspect} | IP=#{request.ip} | Path=#{request.path.inspect} | Throttle=#{matched_throttle.inspect}") + + # Only redirect to Turnstile for /results and /record if it's a throttle with grace period support + if (request.path.start_with?('/results') || request.path.start_with?('/record')) && + (matched_throttle == 'results/global' || matched_throttle == 'req/ip/results') + # Redirect to Turnstile challenge + return_to = "#{request.path_info}?#{request.query_string}".gsub(/\?$/, '') + [ 302, + { 'Location' => "/turnstile?return_to=#{ERB::Util.url_encode(return_to)}" }, + [''] ] + else + # Default 429 for other throttled paths or throttles without grace period support + [ 429, + { 'Content-Type' => 'text/plain' }, + ['Too Many Requests'] ] + end + end # Block suspicious requests for '/etc/password' or wordpress specific paths. # After 3 blocked requests in 10 minutes, block all requests from that IP for 5 minutes. @@ -109,13 +221,16 @@ class Rack::Attack end # Log when throttles are triggered - ActiveSupport::Notifications.subscribe("throttle.rack_attack") do |name, start, finish, request_id, payload| - @@rack_logger ||= ActiveSupport::TaggedLogging.new(Logger.new(STDOUT)) - @@rack_logger.info{[ - "[#{payload[:request].env['rack.attack.match_type']}]", - "[#{payload[:request].env['rack.attack.matched']}]", - "[#{payload[:request].env['rack.attack.match_discriminator']}]", - "[#{payload[:request].env['rack.attack.throttle_data']}]", - ].join(' ') } + # Set RACK_ATTACK_VERBOSE_LOGGING=false to disable (useful for quieter test output) + if ENV.fetch('RACK_ATTACK_VERBOSE_LOGGING', 'true') == 'true' + ActiveSupport::Notifications.subscribe("throttle.rack_attack") do |name, start, finish, request_id, payload| + @@rack_logger ||= ActiveSupport::TaggedLogging.new(Logger.new(STDOUT)) + @@rack_logger.info{[ + "[#{payload[:request].env['rack.attack.match_type']}]", + "[#{payload[:request].env['rack.attack.matched']}]", + "[#{payload[:request].env['rack.attack.match_discriminator']}]", + "[#{payload[:request].env['rack.attack.throttle_data']}]", + ].join(' ') } + end end end diff --git a/test/controllers/turnstile_controller_test.rb b/test/controllers/turnstile_controller_test.rb index a12e5e9d..2917d97e 100644 --- a/test/controllers/turnstile_controller_test.rb +++ b/test/controllers/turnstile_controller_test.rb @@ -44,4 +44,86 @@ def with_bot_detection_enabled assert session[:passed_turnstile] end end + + test 'verify sets turnstile_verified_at signed cookie for grace period' do + with_bot_detection_enabled do + post turnstile_verify_path, + params: { 'cf-turnstile-response' => 'mocked', return_to: '/results?q=test' } + + # Check that the Set-Cookie header includes turnstile_verified_at + assert_match(/turnstile_verified_at/, response.headers['Set-Cookie'].to_s, + 'Response should set turnstile_verified_at cookie') + end + end + + test 'verify grace period duration respects TURNSTILE_GRACE_PERIOD env var' do + with_bot_detection_enabled do + grace_period_minutes = 5 + + ClimateControl.modify(TURNSTILE_GRACE_PERIOD: grace_period_minutes.to_s) do + freeze_time do + post turnstile_verify_path, + params: { 'cf-turnstile-response' => 'mocked', return_to: '/results?q=test' } + + signed_value = cookies[:turnstile_verified_at] + assert signed_value.present?, 'Response should set turnstile_verified_at cookie with custom grace period' + + expiration_timestamp = Rails.application.message_verifier(:turnstile_grace).verify(signed_value) + expected_expiry = (Time.current + grace_period_minutes.minutes).to_i + assert_in_delta expected_expiry, expiration_timestamp, 5, + "Cookie timestamp should be ~#{grace_period_minutes} minutes in the future" + assert_redirected_to '/results?q=test' + end + end + end + end + + test 'verify applies default grace period when TURNSTILE_GRACE_PERIOD env var not set' do + with_bot_detection_enabled do + # Explicitly ensure env var is not set + ClimateControl.modify(TURNSTILE_GRACE_PERIOD: nil) do + freeze_time do + post turnstile_verify_path, + params: { 'cf-turnstile-response' => 'mocked', return_to: '/results?q=test' } + + signed_value = cookies[:turnstile_verified_at] + assert signed_value.present?, 'Response should set turnstile_verified_at cookie with default grace period' + + expiration_timestamp = Rails.application.message_verifier(:turnstile_grace).verify(signed_value) + expected_expiry = (Time.current + 15.minutes).to_i + assert_in_delta expected_expiry, expiration_timestamp, 5, + 'Cookie timestamp should be ~15 minutes in the future (default grace period)' + end + end + end + end + + test 'different requests both set turnstile_verified_at cookie' do + with_bot_detection_enabled do + # First request + post turnstile_verify_path, + params: { 'cf-turnstile-response' => 'mocked', return_to: '/results?q=test1' } + assert_match(/turnstile_verified_at/, response.headers['Set-Cookie'].to_s, + 'Response should set turnstile_verified_at cookie after first verification') + + # Second request + post turnstile_verify_path, + params: { 'cf-turnstile-response' => 'mocked', return_to: '/results?q=test2' } + assert_match(/turnstile_verified_at/, response.headers['Set-Cookie'].to_s, + 'Response should set turnstile_verified_at cookie after second verification') + end + end + + test 'failed Turnstile verification does not set cookie' do + with_bot_detection_enabled do + # Attempt to verify without valid token + post turnstile_verify_path, + params: { return_to: '/results?q=test' } + + # Cookie should NOT be set on failed verification (no Set-Cookie header for turnstile_verified_at) + cookie_header = response.headers['Set-Cookie'].to_s + assert_no_match(/turnstile_verified_at/, cookie_header, + 'Cookie turnstile_verified_at should not be set when Turnstile verification fails') + end + end end diff --git a/test/integration/rack_attack_blocklist_test.rb b/test/integration/rack_attack_blocklist_test.rb new file mode 100644 index 00000000..4eb7307f --- /dev/null +++ b/test/integration/rack_attack_blocklist_test.rb @@ -0,0 +1,22 @@ +require 'test_helper' + +class RackAttackBlocklistTest < ActionDispatch::IntegrationTest + def test_blocked_user_agent_returns_403 + get '/results', params: { q: 'test' }, headers: { 'HTTP_USER_AGENT' => 'Sogou web spider/4.0' } + assert_equal 403, status + end + + def test_blocklist_with_partial_user_agent_match + # 'Sogou web spider' should match 'Sogou web spider/4.0 (compatible; like Gecko)' + # via include? partial string match + get '/results', params: { q: 'test' }, + headers: { 'HTTP_USER_AGENT' => 'Sogou web spider/4.0 (compatible; like Gecko)' } + assert_equal 403, status + end + + def test_blocked_user_agent_substring_match + # Verify that partial matches work + get '/results', params: { q: 'test' }, headers: { 'HTTP_USER_AGENT' => 'Sogou web spider' } + assert_equal 403, status + end +end diff --git a/test/integration/rack_attack_cookie_bypass_test.rb b/test/integration/rack_attack_cookie_bypass_test.rb new file mode 100644 index 00000000..9afcf948 --- /dev/null +++ b/test/integration/rack_attack_cookie_bypass_test.rb @@ -0,0 +1,181 @@ +require 'test_helper' + +class RackAttackCookieBypassTest < ActionDispatch::IntegrationTest + # Test that grace period cookies actually bypass throttling. + # Makes rapid requests to naturally trigger Rack::Attack throttle counters, + # then tests if valid/invalid cookies bypass throttles accordingly. + + def setup + # Clear cache before each test + Rails.cache.clear + + # Stub PrimoSearch to avoid external API calls + stub_primo_search + + # Stub TimdexBase::Client to avoid external GraphQL calls + stub_timdex_client + end + + def stub_primo_search + PrimoSearch.any_instance.stubs(:search).returns( + { + 'info' => { + 'total' => 100, + 'first' => 1, + 'last' => 10 + }, + 'docs' => [ + { + 'pnx' => { + 'addata' => { + 'title' => ['Test Result'] + } + } + } + ] + } + ) + end + + def stub_timdex_client + mock_response = Object.new + mock_response.stubs(:data).returns(Object.new) + mock_response.stubs(:errors).returns(Object.new) + + # Mock data.to_h + mock_response.data.stubs(:to_h).returns( + { + 'recordId' => { + 'title' => 'Test Record', + 'timdexRecordId' => 'test-id' + } + } + ) + + # Mock errors.details.to_h + mock_response.errors.stubs(:details).returns(Object.new) + mock_response.errors.details.stubs(:to_h).returns({}) + + TimdexBase::Client.stubs(:query).returns(mock_response) + end + + def trigger_throttle_via_requests(endpoint_path, count, cookie_value = nil, params:) + # Make rapid requests to trigger Rack::Attack counter + # Each request increments the cache counter for this IP/throttle + count.times do + headers = {} + headers['HTTP_COOKIE'] = "turnstile_verified_at=#{cookie_value}" if cookie_value + get endpoint_path, params: params, headers: headers + end + end + + test 'valid grace period cookie bypasses throttle when throttle is active' do + # Get the throttle limit from environment or use default + limit = ENV.fetch('RESULTS_GLOBAL_LIMIT_PER_SEC', 30).to_i + + # Create a valid, non-expired cookie + future_timestamp = (Time.current + 15.minutes).to_i + verifier = Rails.application.message_verifier(:turnstile_grace) + valid_cookie = verifier.generate(future_timestamp) + + # Make requests WITHOUT cookie to build up throttle counter + trigger_throttle_via_requests('/results', limit + 5, params: { q: 'test', tab: 'primo' }) + + # Now make a request WITH valid cookie - should bypass throttle + get '/results', params: { q: 'test', tab: 'primo' }, + headers: { 'HTTP_COOKIE' => "turnstile_verified_at=#{valid_cookie}" } + + # Should NOT be throttled - valid cookie bypasses the throttle + assert_response :success, 'Valid grace period cookie should bypass throttle' + end + + test 'invalid cookie does not bypass throttle when throttle is active' do + # Get the throttle limit + limit = ENV.fetch('RESULTS_GLOBAL_LIMIT_PER_SEC', 30).to_i + + # Create an invalid/tampered cookie + invalid_cookie = 'tampered-value-that-will-not-verify' + + # Build up throttle counter without any cookie + trigger_throttle_via_requests('/results', limit + 5, params: { q: 'test', tab: 'primo' }) + + # Now make a request WITH invalid cookie - should still be throttled + get '/results', params: { q: 'test', tab: 'primo' }, + headers: { 'HTTP_COOKIE' => "turnstile_verified_at=#{invalid_cookie}" } + + # Should be throttled and redirected to Turnstile (not bypassed) + assert_equal 302, status, 'Invalid cookie should not bypass throttle' + assert response.location.include?('/turnstile'), 'Should redirect to Turnstile on throttle' + end + + test 'expired cookie does not bypass throttle when throttle is active' do + limit = ENV.fetch('RESULTS_GLOBAL_LIMIT_PER_SEC', 30).to_i + + # Create a validly-signed but expired cookie + past_timestamp = (Time.current - 1.minute).to_i + verifier = Rails.application.message_verifier(:turnstile_grace) + expired_cookie = verifier.generate(past_timestamp) + + # Build up throttle counter + trigger_throttle_via_requests('/results', limit + 5, params: { q: 'test', tab: 'primo' }) + + # Request with expired cookie should be throttled + get '/results', params: { q: 'test', tab: 'primo' }, + headers: { 'HTTP_COOKIE' => "turnstile_verified_at=#{expired_cookie}" } + + # Should be throttled and redirected + assert_equal 302, status, 'Expired cookie should not bypass throttle' + assert response.location.include?('/turnstile'), 'Should redirect to Turnstile on throttle' + end + + test 'missing cookie does not bypass throttle when throttle is active' do + limit = ENV.fetch('RESULTS_GLOBAL_LIMIT_PER_SEC', 30).to_i + + # Build up throttle counter + trigger_throttle_via_requests('/results', limit + 5, params: { q: 'test', tab: 'primo' }) + + # Request without any turnstile_verified_at cookie + get '/results', params: { q: 'test', tab: 'primo' } + + # Should be throttled and redirected + assert_equal 302, status, 'Missing cookie should not bypass throttle' + assert response.location.include?('/turnstile'), 'Should redirect to Turnstile on throttle' + end + + test 'malformed cookie does not bypass throttle when throttle is active' do + limit = ENV.fetch('RESULTS_GLOBAL_LIMIT_PER_SEC', 30).to_i + + # Create a malformed cookie + malformed_cookie = ';;;invalid;;;not-base64;;;' + + # Build up throttle counter + trigger_throttle_via_requests('/results', limit + 5, params: { q: 'test', tab: 'primo' }) + + # Request with malformed cookie + get '/results', params: { q: 'test', tab: 'primo' }, + headers: { 'HTTP_COOKIE' => "turnstile_verified_at=#{malformed_cookie}" } + + # Should be throttled and redirected + assert_equal 302, status, 'Malformed cookie should not bypass throttle' + assert response.location.include?('/turnstile'), 'Should redirect to Turnstile on throttle' + end + + test 'valid cookie bypasses req/ip/results throttle on /record endpoint' do + # Test that valid cookies work for /record endpoint (which has req/ip/results throttle) + # Use the same env vars as the actual throttle configuration + limit = ENV.fetch('RESULTS_THROTTLE_LIMIT', 10).to_i + + future_timestamp = (Time.current + 15.minutes).to_i + verifier = Rails.application.message_verifier(:turnstile_grace) + valid_cookie = verifier.generate(future_timestamp) + + # Build up throttle counter for /record endpoint + trigger_throttle_via_requests('/record/test-id', limit + 5, params: {}) + + # Now make request with valid cookie - should bypass throttle + get '/record/test-id', headers: { 'HTTP_COOKIE' => "turnstile_verified_at=#{valid_cookie}" } + + # Should NOT be throttled + assert_response :success, 'Valid grace period cookie should bypass throttle on /record' + end +end diff --git a/test/integration/rack_attack_throttle_429_test.rb b/test/integration/rack_attack_throttle_429_test.rb new file mode 100644 index 00000000..ac7afb98 --- /dev/null +++ b/test/integration/rack_attack_throttle_429_test.rb @@ -0,0 +1,95 @@ +require 'test_helper' + +class RackAttackThrottle429Test < ActionDispatch::IntegrationTest + # Test that throttles without grace period support (req/ip, req/ip/redirects) + # return a 429 response instead of redirecting to Turnstile. + # + # Note: These tests use mocking to simulate Rack::Attack throttle conditions + # because env vars are read at class load time, not request time. We test the + # response logic by directly calling the throttled_responder lambda. + + def build_mock_env(path: '/results', query_string: '', matched_throttle: 'req/ip') + { + 'REQUEST_METHOD' => 'GET', + 'PATH_INFO' => path, + 'QUERY_STRING' => query_string, + 'SERVER_NAME' => 'localhost', + 'SERVER_PORT' => '80', + 'rack.url_scheme' => 'http', + 'rack.attack.matched' => matched_throttle, + 'HTTP_USER_AGENT' => 'Mozilla/5.0' + } + end + + test 'req/ip throttle returns 429 (not Turnstile redirect)' do + # The req/ip throttle has no grace period, so it should return 429 + env = build_mock_env(path: '/about', matched_throttle: 'req/ip') + + status, headers, body = Rack::Attack.throttled_responder.call(env) + + assert_equal 429, status + assert_equal 'text/plain', headers['Content-Type'] + assert_equal ['Too Many Requests'], body + end + + test 'req/ip/redirects throttle returns 429 (not Turnstile redirect)' do + # The req/ip/redirects throttle has no grace period, so it should return 429 + env = build_mock_env(path: '/', query_string: 'geoweb-redirect=primo', matched_throttle: 'req/ip/redirects') + + status, headers, body = Rack::Attack.throttled_responder.call(env) + + assert_equal 429, status + assert_equal 'text/plain', headers['Content-Type'] + assert_equal ['Too Many Requests'], body + end + + test 'results/global throttle redirects to Turnstile (not 429)' do + # The results/global throttle has grace period support, so it should redirect + env = build_mock_env(path: '/results', query_string: 'q=test', matched_throttle: 'results/global') + + status, headers, _body = Rack::Attack.throttled_responder.call(env) + + # Should redirect to Turnstile, not return 429 + assert_equal 302, status + assert headers['Location'].include?('/turnstile') + assert headers['Location'].include?('return_to=') + end + + test 'req/ip/results throttle redirects to Turnstile (not 429)' do + # The req/ip/results throttle has grace period support, so it should redirect + env = build_mock_env(path: '/record/123', matched_throttle: 'req/ip/results') + + status, headers, _body = Rack::Attack.throttled_responder.call(env) + + # Should redirect to Turnstile, not return 429 + assert_equal 302, status + assert headers['Location'].include?('/turnstile') + end + + test 'throttled_responder logs all throttled requests' do + # Verify that logging happens for all throttled requests + env = build_mock_env(path: '/about', matched_throttle: 'req/ip') + + # Expect a warn-level log + Rails.logger.expects(:warn).with do |msg| + msg.include?('THROTTLED_REQUEST') && + msg.include?('UA=') && + msg.include?('IP=') && + msg.include?('Path=') && + msg.include?('Throttle=') + end.at_least_once + + Rack::Attack.throttled_responder.call(env) + end + + test '429 response includes plain text content type and body' do + # Verify the exact response format for 429 errors + env = build_mock_env(path: '/api/endpoint', matched_throttle: 'req/ip') + + status, headers, body = Rack::Attack.throttled_responder.call(env) + + assert_equal 429, status + assert_equal 'text/plain', headers['Content-Type'] + assert_equal 'Too Many Requests', body.first + end +end diff --git a/test/models/bot_detector_test.rb b/test/models/bot_detector_test.rb index a8ee1d0a..71118b2f 100644 --- a/test/models/bot_detector_test.rb +++ b/test/models/bot_detector_test.rb @@ -1,5 +1,4 @@ require 'test_helper' -require 'ostruct' class BotDetectorTest < ActiveSupport::TestCase # Helper method to instantiate request objects.