Combine turnstile and rack-attack#410
Conversation
Coverage Report for CI Build 28886842527Coverage increased (+0.004%) to 98.384%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
❌ 4 blocking issues (4 total)
|
There was a problem hiding this comment.
Pull request overview
Adds Rack::Attack-based protections for the expensive /results and /record endpoints and ties throttling into the existing Turnstile flow so legitimate users can recover from throttling via challenge verification.
Changes:
- Introduces a global RPS throttle plus a stricter per-IP throttle for
/resultsand/record. - Overrides Rack::Attack throttled responses on those endpoints to redirect to the Turnstile challenge instead of returning 429.
- After successful Turnstile verification, writes a short-lived “verified” cache key intended to suppress re-challenges for that IP.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 8 comments.
| File | Description |
|---|---|
| README.md | Documents new ENV knobs for the results/record throttles and Turnstile grace period. |
| config/initializers/rack_attack.rb | Adds new throttles and custom throttled response logic for /results and /record. |
| app/controllers/turnstile_controller.rb | Writes a cache-based grace-period marker after successful Turnstile verification. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
jazairi
left a comment
There was a problem hiding this comment.
I think all of Copilot's suggested feedback is worth implementing, particularly the fix for the potential feedback loop. Once that is in place, this is ready to merge from my perspective.
I was pondering adding unit tests for the grace period and throttle_response logic, but I couldn't figure a good way to do so that wouldn't just be testing implementation details. In this case, I think manual testing is sufficient. Anything more would likely have to be a system test, which would be new to us and well out of scope of this urgent change.
4a17ed5 to
83f8ac3
Compare
83f8ac3 to
82fe6e0
Compare
| 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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
I'm not sure what hallucination this is, but this test passes and it seems like it should error if this was correct 🤷🏻
| 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 |
There was a problem hiding this comment.
Yeah, I don't know how likely this is to occur in reality.
82fe6e0 to
1ff05c3
Compare
| same_site: :lax | ||
| } | ||
|
|
||
| redirect_to safe_return_path |
jazairi
left a comment
There was a problem hiding this comment.
This is looking really good! I think we need a bit more test coverage, though. Let me know if you disagree.
| 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 |
There was a problem hiding this comment.
Yeah, I don't know how likely this is to occur in reality.
| 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 |
There was a problem hiding this comment.
I'm not sure this condition is covered in the test suite. If not, could we add something to ensure that requests with tampered cookies redirect to Turnstile?
There was a problem hiding this comment.
Related to this, I feel like we should confirm that expired cookies are handled as expected. (I.e., when the grace period expires, the request is referred to Turnstile.)
| { 'Location' => "/turnstile?return_to=#{ERB::Util.url_encode(return_to)}" }, | ||
| [''] ] | ||
| else | ||
| # Default 429 for other throttled paths or throttles without grace period support |
There was a problem hiding this comment.
We should probably confirm this condition in the test suite. If something goes wrong with it, I think the user agent could end up in an infinite loop.
58dfcd9 to
849e9f8
Compare
| mock_response.errors.stubs(:details).returns(Object.new) | ||
| mock_response.errors.details.stubs(:to_h).returns({}) | ||
|
|
||
| TimdexBase::Client.stubs(:query).returns(mock_response) |
| - `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. |
| 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 |
| 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 | ||
|
|
jazairi
left a comment
There was a problem hiding this comment.
Looks good! Appreciate the logging tweak, as that was a minor concern I had. 🙂
- Add comprehensive integration tests for grace period cookie bypass behavior, throttle response codes (429 vs redirects), and edge cases like expired/invalid/malformed cookies. - Updated the `throttled_responder` to handle both Hash env and Rack::Request objects (for easier testing) - Add `RACK_ATTACK_VERBOSE_LOGGING` environment variable (default `true`) to allow disabling detailed throttle event logging to stdout, useful for reducing test output noise while maintaining throttle functionality.
6fb3835 to
7f8956d
Compare
Developer
Accessibility
New ENV
Approval beyond code review
Additional context needed to review
E.g., if the PR includes updated dependencies and/or data
migration, or how to confirm the feature is working.
Code Reviewer
Code
added technical debt.
Documentation
(not just this pull request message).
Testing