Skip to content

Commit 089e009

Browse files
iHiDclaude
andauthored
Rate-limit unauthenticated crawling of profile solutions (#9421)
api/profiles/solutions#index is enumerable - one URL per user handle, and handles are public - unauthenticated, and cheap to request. It had no GET throttle at all, so it belongs in the existing crawl throttle alongside the exercise pages and submission files. Found while breaking down CloudWatch log ingestion: a single netcup VPS (2a0a:4cc0:2000:2e9b:1434:8eff:feda:ee1) has been walking the handle space at a flat ~1,250 req/hr around the clock - 30,075 404s to 68 200s in 24 hours, roughly 900k requests/month. Nothing stopped it. Cloudflare's "60 in 1m" rule is scoped `http.host eq "exercism.org"` and this traffic arrives on api.exercism.org, so that rule never evaluates. Fixing it here rather than at the edge covers both hostnames at once and survives the next IP. Note that api.exercism.org cannot simply be put behind the same bot challenge as exercism.org, because the CLI talks to api.exercism.io/v1. Claude-Session: https://claude.ai/code/session_01WRm6FbLza51hYVjkTSuGju Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent bc559ca commit 089e009

2 files changed

Lines changed: 35 additions & 7 deletions

File tree

config/initializers/rack_attack.rb

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -70,21 +70,22 @@ def signed_in?
7070
req.env['HTTP_X_SEARCH_ENGINE'] == 'true'
7171
end
7272

73-
# These two endpoints are cheap to request and expensive to serve, and are
74-
# enumerable: there is one URL per exercise and one per submission, so anything
75-
# walking the site systematically can pull an unbounded number of them. Cap
76-
# unauthenticated access per-IP per-day. The limit is far above what any
77-
# person browsing the site would reach.
73+
# These endpoints are cheap to request and expensive to serve, and are
74+
# enumerable: there is one URL per exercise, one per submission, and one per
75+
# user handle, so anything walking the site systematically can pull an
76+
# unbounded number of them. Cap unauthenticated access per-IP per-day. The
77+
# limit is far above what any person browsing the site would reach.
7878
CRAWLED_EXERCISE_PATH = %r{\A/tracks/[^/]+/exercises/[^/]+(/|\z)}
7979
Rack::Attack.throttle("Unauthenticated crawling of expensive endpoints", limit: 500, period: 1.day) do |req|
8080
next unless req.get?
8181
next if req.signed_in?
8282

83-
# Only resolve the route for the API endpoint, and only once we know the
83+
# Only resolve the route for the API endpoints, and only once we know the
8484
# path is in the right namespace: recognize_path walks the whole route set,
8585
# which is far too expensive to pay on every unauthenticated GET.
8686
matches = req.path.match?(CRAWLED_EXERCISE_PATH) ||
87-
(req.path.starts_with?('/api/v2/solutions') && req.routed_to == 'api/solutions/submission_files#index')
87+
(req.path.starts_with?('/api/v2/solutions') && req.routed_to == 'api/solutions/submission_files#index') ||
88+
(req.path.starts_with?('/api/v2/profiles') && req.routed_to == 'api/profiles/solutions#index')
8889
next unless matches
8990

9091
"unauthenticated-crawl|#{req.client_ip}"

test/initializers/rack_attack_test.rb

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,33 @@ class RackAttackTest < Webhooks::BaseTestCase
224224
end
225225
end
226226

227+
test "unauthenticated profile solutions requests over the limit are throttled" do
228+
profile_user = create(:user_profile).user
229+
ip = "1.2.3.20"
230+
231+
freeze_time do
232+
fill_crawl_throttle(ip, 500)
233+
234+
get api_profile_solutions_path(profile_user), headers: crawler_headers(ip)
235+
assert_response :too_many_requests
236+
end
237+
end
238+
239+
# Enumerating the handle space mostly produces 404s. Rack::Attack is
240+
# middleware, so it counts the request before the app decides the profile
241+
# doesn't exist — which is the whole point here, since a miss is exactly what
242+
# a crawler walking usernames generates.
243+
test "profile solutions requests for handles that do not exist still count towards the crawl throttle" do
244+
ip = "1.2.3.21"
245+
246+
freeze_time do
247+
fill_crawl_throttle(ip, 500)
248+
249+
get api_profile_solutions_path("no-such-handle"), headers: crawler_headers(ip)
250+
assert_response :too_many_requests
251+
end
252+
end
253+
227254
test "authenticated requests are not throttled by the crawl throttle" do
228255
create :practice_exercise
229256
ip = "1.2.3.7"

0 commit comments

Comments
 (0)