A turnstile in an empty station with an open gap in the railing right beside it.
Security & Privacy

Our rate limits were bypassed by a trailing slash

Five tuned Rack::Attack throttles, all matching the path exactly. Rails normalised the path after they ran, so a dot-json or a trailing slash bypassed every one.

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.