- limiter_key: an empty client_ip(req) under trust_proxy no longer keys on the literal "ip:" -- falls through to net.peer(req.conn) instead, same as the untrusted-default path - the bug: every client omitting X-Forwarded-For shared ONE bucket, so one could exhaust it and deny/hide the rest - curl availability check added alongside the existing woc/wovm check (the limiter gate legs drive the server with it) - new gate leg: LIMIT+1 sequential no-XFF requests must all be 200 (own key per connection, via a fresh ephemeral port each time) -- confirmed it fails against the pre-fix code (6th comes back 429) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit 831e9d8e6b1ef39cd938783dbc47c1abe6d53211)
106 lines
4.5 KiB
Text
106 lines
4.5 KiB
Text
-- porch/middleware/limiter.wo — rate limiter middleware. All counting is
|
|
-- delegated to the key pool (keypool.wo): this file never reads or writes
|
|
-- RateLimitCounter and holds no window arithmetic of its own. That is what
|
|
-- makes the pool's per-key serialization guarantee actually apply — a
|
|
-- store call here would be a second, uncoordinated writer.
|
|
-- Iteration 1 of the porch track, porch-store task 3.
|
|
|
|
use time
|
|
use http
|
|
use net
|
|
|
|
-- Limiter counts requests per key per window by calling into the shared
|
|
-- pool and acting on the Verdict it returns.
|
|
-- On limit exceeded: 429 with Retry-After and X-RateLimit-* headers.
|
|
-- On a saturated pool (the key's actor mailbox is full under load): 503
|
|
-- with Retry-After. The request is refused, never let through — a limiter
|
|
-- that stops limiting under load is worse than no limiter, since
|
|
-- saturating the pool would otherwise be the bypass.
|
|
-- Key selection: req.principal wins when non-empty. Otherwise, trust_proxy
|
|
-- false (default) keys on net.peer(req.conn), which cannot be forged;
|
|
-- trust_proxy true keys on client_ip(req) (the left-most X-Forwarded-For
|
|
-- entry) — the app author's assertion that a proxy they control overwrites
|
|
-- that header.
|
|
-- The refused/saturated paths stamp their own headers directly on the Resp
|
|
-- they return. The allowed path has no Resp yet to stamp — before() stashes
|
|
-- the numbers on req.ctx, and `after` (same shape as Cors: register the one
|
|
-- value as both Mw and Aw) copies them onto whatever response the chain
|
|
-- eventually produces.
|
|
pub class Limiter {
|
|
pool: Pool
|
|
limit: Int
|
|
window: Int -- window size in µs (e.g., 60_000_000 = 60s)
|
|
trust_proxy: Bool = false
|
|
|
|
fn before(mut req: Req) -> ?Resp {
|
|
let key = limiter_key(self, req);
|
|
let v = try pool_count(self.pool, key, self.limit, self.window) catch (e) nil;
|
|
|
|
if v == nil {
|
|
let r = Resp { status: 503, headers: {}, body: "{\"error\":\"rate limiter saturated\"}" };
|
|
set_header(r, "content-type", "application/json");
|
|
set_header(r, "retry-after", "1");
|
|
return r;
|
|
}
|
|
|
|
let limit_hdr = "${v.limit}";
|
|
let remaining = v.limit - v.count;
|
|
if remaining < 0 { remaining = 0; }
|
|
let reset_hdr = "${v.reset_at / 1000}"; -- wall-clock ms -> Unix seconds
|
|
|
|
if v.allowed == false {
|
|
let retry_sec = ((v.reset_at - time.now()) / 1000) + 1;
|
|
let r = Resp { status: 429, headers: {}, body: "{\"error\":\"rate limit exceeded\"}" };
|
|
set_header(r, "content-type", "application/json");
|
|
set_header(r, "x-ratelimit-limit", limit_hdr);
|
|
set_header(r, "x-ratelimit-remaining", "0");
|
|
set_header(r, "x-ratelimit-reset", reset_hdr);
|
|
set_header(r, "retry-after", "${retry_sec}");
|
|
return r;
|
|
}
|
|
|
|
-- Allowed: attach headers to request for after-chain to stamp on response
|
|
req.ctx["ratelimit_limit"] = limit_hdr;
|
|
req.ctx["ratelimit_remaining"] = "${remaining}";
|
|
req.ctx["ratelimit_reset"] = reset_hdr;
|
|
|
|
return nil;
|
|
}
|
|
|
|
-- Stamps the allowed-path numbers before() stashed. A refused/saturated
|
|
-- request never reaches here with anything to stamp (before() only
|
|
-- writes ctx on the allowed path), so this is a no-op for those.
|
|
fn after(req: Req, mut r: Resp) {
|
|
let limit_hdr = req.ctx["ratelimit_limit"];
|
|
let remaining_hdr = req.ctx["ratelimit_remaining"];
|
|
let reset_hdr = req.ctx["ratelimit_reset"];
|
|
if limit_hdr == nil { return; }
|
|
if remaining_hdr == nil { return; }
|
|
if reset_hdr == nil { return; }
|
|
r.headers["x-ratelimit-limit"] = limit_hdr;
|
|
r.headers["x-ratelimit-remaining"] = remaining_hdr;
|
|
r.headers["x-ratelimit-reset"] = reset_hdr;
|
|
}
|
|
}
|
|
|
|
-- Key selection: identity first, then the trust_proxy-gated peer address.
|
|
-- An absent X-Forwarded-For under trust_proxy means there is nothing to
|
|
-- trust, not an empty identity: client_ip(req) == "" falls through to
|
|
-- net.peer(req.conn) rather than keying every such client on the literal
|
|
-- "ip:" bucket (that collapse was a real bug -- one client omitting the
|
|
-- header could exhaust the shared bucket and deny/hide the rest).
|
|
pub fn limiter_key(self: Limiter, req: Req) -> Text {
|
|
if req.principal != "" { return "principal:${req.principal}"; }
|
|
if self.trust_proxy {
|
|
let ip = client_ip(req);
|
|
if ip != "" { return "ip:${ip}"; }
|
|
}
|
|
let peer = net.peer(req.conn);
|
|
if peer != "" { return "ip:${peer}"; }
|
|
return "unknown";
|
|
}
|
|
|
|
-- Helper to build a Limiter with defaults
|
|
pub fn make_limiter(pool: Pool, limit: Int, window_sec: Int) -> Limiter {
|
|
return Limiter { pool: pool, limit: limit, window: window_sec * 1_000_000 };
|
|
}
|