We added five throttles for a new public endpoint. Three on the expensive
POST (a burst limit, a per-minute limit, a daily limit), one on the polling
GET, one on a second POST. Each was a one-liner in the shape every
Rack::Attack tutorial shows:
throttle("public_scan/burst", limit: 3, period: 10.seconds) do |req|
req.ip if req.post? && req.path == "/api/v1/public/scans"
end
We tested them. Four rapid posts, the fourth came back 429. Shipped.
A reviewer then ran a small routing probe and reported that all of these requests reached the same controller action:
POST /api/v1/public/scans
POST /api/v1/public/scans/
POST /api/v1/public/scans.json
POST /api/v1/public//scans
Only the first one matched our throttle. The other three matched none of the five. They fell through to the generic per-IP limit for the whole API — several times more permissive per minute than the endpoint's own limit, and effectively unbounded against its daily one. Every limit we had tuned for that endpoint was optional.
Why the exact match lies
The mismatch is about when things happen. Rack::Attack is middleware. It runs
early, on the raw request, before routing. Rails' router (Journey) runs later,
and it is deliberately forgiving: it strips a trailing slash, it peels off a
.format suffix and turns it into params[:format], and it collapses repeated
slashes. So /scans.json/ and /scans are the same route to Rails.
To Rack::Attack they are different strings. req.path == "/api/v1/public/scans"
is a byte comparison against a path Rails has not touched yet.
This is not a Rack::Attack bug. It is a boundary mismatch: two components with different ideas of what "the path" is, and the security check living on the stricter side. It is also not new; the same pattern was already sitting in older throttles in the same file, unnoticed for months, because nobody had tested a variant. We only found it because a reviewer thought to.
Canonicalise before you compare
The fix is to normalise the path the same way the router will, then match:
CANON = ->(path) { path.to_s.squeeze("/").chomp("/").sub(%r{\.[^/.]+\z}, "") }
throttle("public_scan/burst", limit: 3, period: 10.seconds) do |req|
req.ip if req.post? && CANON.call(req.path) == "/api/v1/public/scans"
end
Three operations, and the order matters. We got it wrong the first time.
Our first canonicaliser stripped the extension before chomping the slash, and
only recognised [A-Za-z0-9] as an extension. That closed /scans.json and
/scans/ but left three new holes, which the same reviewer found on the second
pass:
POST /api/v1/public/scans.json/ ext-then-slash: the slash blocked the ext strip
POST /api/v1/public/scans.a-b hyphen isn't alnum, so ".a-b" survived
POST /api/v1/public/scans.j%73on percent-escape, same reason
Journey's format segment is [^/.]+, which includes hyphens and percent
signs. And Journey chomps the slash first. So the canonicaliser has to do the
same: squeeze slashes, chomp the trailing one, then strip a single trailing
.something using the router's own character class. Do it in that order and
every variant collapses to the throttled path.
We probed all eight shapes against both versions before committing the second one. The probe is a ten-line Ruby script and it should have been the first thing we wrote, not the last.
What it was actually protecting
It is worth being concrete about why this mattered, because "a throttle was
bypassable" sounds abstract. The POST in question can hold a web thread for
several seconds parsing an uploaded file, and a web server has only a small,
fixed pool of those threads. Run the arithmetic for any modest deployment —
say a parse that holds a thread for five seconds, a pool of a few threads,
and a generic limit of sixty requests a minute: that one IP can demand three
hundred thread-seconds a minute from a pool that only has a fraction of that
to give. One attacker with a trailing slash saturates the API for everyone,
and because the parse fails before any quota is charged, they are never even
counted.
The tuned throttles brought that down to a few dozen parses a day per IP. The bypass turned them back off.
Two habits worth keeping
Test the variants, not just the happy path. A rate-limit test that posts to
the canonical URL four times proves the throttle exists. It proves nothing
about the throttle's coverage. Add one more test that posts to .json/ and
expects the same 429. It is the cheapest security test you will write this
year.
When a check runs before a normaliser, normalise inside the check. Any
middleware-level rule keyed on a URL, a header, a hostname, or a content type is
comparing against a value that some later layer will reinterpret. Either move
the check after the reinterpretation, or reproduce the reinterpretation
yourself, and copy the exact rules the later layer uses (in our case, Journey's
[^/.]+), because "close enough" is where the second round of holes lives.
The bypass variants live on as request specs now — post the dot-format and
ext-plus-slash shapes, expect the same 429. They are short, they are boring,
and they pin the canonicaliser against the exact regressions that produced
them. The next path-keyed throttle gets its own variant specs on the day it
is written, not after its own reviewer finds the hole.
