Rate Limit Bypassed by a Trailing Slash and a Case Change
You limit /login to five attempts a minute. An attacker sends five to /login, five to /login/ and five to /LOGIN, and the router serves all fifteen with the same handler. Nothing is broken in either component. They just disagree about what "the same endpoint" means.
The limiter and the router each have their own idea of a path
The usual setup is a middleware that builds a key, counts hits against it, then calls the router. A very common key is something like ip + ":" + r.URL.Path. It is simple, it looks right, and it compares raw strings.
The router is often more forgiving. Express treats routes as case-insensitive and ignores a trailing slash by default. Django can accept or redirect a missing slash. IIS and any nginx location ~* block match case-insensitively. So the router folds many spellings into one handler, while the limiter keeps them as many buckets.
The limiter has to see the path the way the router will see it. Wherever those two views differ, you have a bypass.
Variants worth testing against your own API
Trailing slash and case are the famous pair, but the idea stretches further:
/login/(trailing slash)/Loginand/LOGIN(case)//loginand/./login(empty and dot segments)/%6cogin(percent-encoded letters, if one layer decodes and the other does not)/login?x=1and/login?x=2(if someone keyed on the full URI, query string included)
That last one is the same bug from the other direction: the key is too specific, so every random query string gets a fresh bucket.
Quick detour: Go's ServeMux fails the other way
Go's standard http.ServeMux is strict. Patterns are case-sensitive, and /login does not match /login/, so those variants get a 404 rather than the handler. It does redirect paths containing // or dot segments to the cleaned form.
That does not make you safe. A strict router plus a limiter keyed on something else (the raw RequestURI, a header, a proxy-rewritten path) can still disagree. And plenty of real stacks sit behind a lenient front end, so the check is always "what does the whole chain accept?", not "what does my framework do?".
Fix 1: canonicalise once, before anything else
Put one normalising middleware at the very front, so the limiter and the router both see the same string. Rewriting a clone of the request keeps the original untouched.
package main
import (
"net"
"net/http"
"path"
"strings"
)
// canonical is only correct if your router really is
// case-insensitive; for a strict router, drop ToLower.
func canonical(p string) string {
return strings.ToLower(path.Clean("/" + p))
}
func canonicalise(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r2 := r.Clone(r.Context())
r2.URL.Path = canonical(r.URL.Path)
r2.URL.RawPath = ""
next.ServeHTTP(w, r2)
})
}
func limitKey(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
return host + " " + r.URL.Path // query string deliberately excluded
}
Wire it as canonicalise(limit(router)), in that order. path.Clean strips the trailing slash, collapses // and resolves . and ... r.URL.Path is already percent-decoded, which handles the %6c trick.
Fix 2: key on the route, not the string
Better still, key on which handler was matched. Since Go 1.23 the mux sets r.Pattern after matching, but only once the mux has run, so you would wrap individual routes rather than the whole mux:
mux := http.NewServeMux()
mux.Handle("POST /login", limit("login", loginHandler))
Here the budget name ("login") is chosen by you, at registration, and cannot be varied by the client. Every spelling the router accepts reaches the same wrapped handler and the same bucket. In other frameworks the equivalent is the matched route template, such as /users/:id, never the concrete URL.
Don't rely on the path at all for the important limits
For login and password reset, the more robust limit is per account and per client, whatever path was used. An attacker who finds one more spelling your normaliser missed should still hit a wall.
- Per username: five failures, then back off, regardless of IP
- Per IP: a coarser global budget across the whole API
- Per route: the tight limit, keyed on the matched route name
Layering means a miss in one layer is not the end of the story. Be careful with per-username limits, though: they let anyone lock a victim out, so back off rather than hard-block.
A client IP is only as good as your proxy config
If you key on IP behind a reverse proxy, RemoteAddr is the proxy for everybody, so one limit covers the whole internet. The opposite mistake is trusting X-Forwarded-For from anyone, which lets the attacker mint a new identity per request. Take the header only from a proxy you control, and only the entry that proxy appended.
How to check it worked
Write a table test that hammers each variant and asserts a 429 arrives after the budget is spent, counted across all of them:
for _, p := range []string{"/login", "/login/", "/LOGIN", "//login", "/./login", "/%6cogin"} {
// send one request to p from the same client
}
// the sixth request in total, whichever path it used, must be 429
A request that gets a 404 from a strict router is fine; what matters is that nothing which reaches the handler escapes the count. Run the same list against the deployed URL as well, since a CDN or proxy in front can change the answer.
One shortcut here: the fix above keeps its counters per process, so with several instances the real limit is the budget times the instance count. Move the counters to shared storage if that matters to you.