fix: per-IP auth rate limiting instead of one global bucket
A single global token bucket let one attacker at the sustained rate 429 every user's login/register/change-password. Key buckets by client IP: the SvelteKit proxy now stamps the real peer address onto X-Forwarded-For (overriding any client-supplied value, anti-spoof), and axum reads it via a ClientIp extractor — but only when AUTH_TRUSTED_PROXY is set, else it falls back to the shared bucket (today's behavior). The per-IP map is bounded (10k IPs, idle buckets pruned) so a spoofed-IP spray can't grow it. AUTH_TRUSTED_PROXY defaults false (safe for a directly-exposed backend); compose sets it true since the proxy is the single trusted hop. Bump to 0.124.12. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -47,6 +47,26 @@ export function stripHopByHopHeaders(src: Headers): Headers {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the real client address onto `x-forwarded-for` for the upstream
|
||||
* request so axum can key its per-IP auth rate limiter on the actual client
|
||||
* (the backend only honours this when `AUTH_TRUSTED_PROXY=true`). We
|
||||
* **override** rather than append, and drop any incoming `x-forwarded-for` /
|
||||
* `x-real-ip`, so a browser can't spoof its own IP to dodge the limit — this
|
||||
* proxy is the single trusted hop. A missing/blank address leaves the headers
|
||||
* untouched (backend then falls back to its shared bucket). Exported for
|
||||
* unit-test coverage.
|
||||
*/
|
||||
export function setForwardedFor(headers: Headers, clientAddress: string | undefined): Headers {
|
||||
headers.delete('x-real-ip');
|
||||
if (clientAddress && clientAddress.trim() !== '') {
|
||||
headers.set('x-forwarded-for', clientAddress.trim());
|
||||
} else {
|
||||
headers.delete('x-forwarded-for');
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cap each proxied request at 5 minutes. The bound exists to surface
|
||||
* a wedged backend (stuck on a slow DB query, deadlocked, etc.) as a
|
||||
@@ -90,6 +110,17 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
const target = `${BACKEND_URL}${event.url.pathname}${event.url.search}`;
|
||||
|
||||
const headers = stripHopByHopHeaders(event.request.headers);
|
||||
// Forward the real client IP for the backend's per-IP auth rate
|
||||
// limiter, overriding any client-supplied value (anti-spoof).
|
||||
// `getClientAddress()` throws if the adapter can't determine it — fall
|
||||
// back to stripping the header so no spoofed value survives.
|
||||
let clientAddress: string | undefined;
|
||||
try {
|
||||
clientAddress = event.getClientAddress();
|
||||
} catch {
|
||||
clientAddress = undefined;
|
||||
}
|
||||
setForwardedFor(headers, clientAddress);
|
||||
|
||||
// AbortController times the upstream fetch out so a backend
|
||||
// wedged on a slow DB query doesn't keep the browser request
|
||||
|
||||
Reference in New Issue
Block a user