Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,13 @@ 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`
Comment thread
JPrevost marked this conversation as resolved.
- `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.
- `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.
Expand Down
41 changes: 41 additions & 0 deletions app/controllers/turnstile_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
qltysh[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 2 issues:

1. Assignment Branch Condition size for verify is too high. [<5, 18, 0> 18.68/17] [rubocop:Metrics/AbcSize]


2. Method has too many lines. [12/10] [rubocop:Metrics/MethodLength]

end

Expand Down
142 changes: 126 additions & 16 deletions config/initializers/rack_attack.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
JPrevost marked this conversation as resolved.
is_blocked
end

### Throttle Spammy Clients ###

# If any single client IP is making tons of requests, then they're
Expand All @@ -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
Comment on lines +71 to +76

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not worth it

# Tampered or invalid cookie — proceed with throttling
end
end
Comment thread
JPrevost marked this conversation as resolved.

# 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

Expand Down Expand Up @@ -81,17 +170,38 @@ 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.
Comment thread
JPrevost marked this conversation as resolved.
#
# For throttles without grace period support, return 429 instead.
self.throttled_responder = lambda do |env|
request = Rack::Request.new(env)
matched_throttle = env['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.
Expand Down
82 changes: 82 additions & 0 deletions test/controllers/turnstile_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
22 changes: 22 additions & 0 deletions test/integration/rack_attack_blocklist_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
require 'test_helper'

class RackAttackBlocklistTest < ActionDispatch::IntegrationTest
def test_blocked_user_agent_returns_403

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use normalcase for method name numbers. [rubocop:Naming/VariableNumber]

get '/results', params: { q: 'test' }, headers: { 'HTTP_USER_AGENT' => 'Sogou web spider/4.0' }
assert_equal 403, status
end
Comment on lines +4 to +7

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what hallucination this is, but this test passes and it seems like it should error if this was correct 🤷🏻


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
Comment on lines +9 to +15

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what hallucination this is, but this test passes and it seems like it should error if this was correct 🤷🏻


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
Comment on lines +17 to +21

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what hallucination this is, but this test passes and it seems like it should error if this was correct 🤷🏻

end
1 change: 0 additions & 1 deletion test/models/bot_detector_test.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
require 'test_helper'
require 'ostruct'

class BotDetectorTest < ActiveSupport::TestCase
# Helper method to instantiate request objects.
Expand Down