src/rate-limit.ts
101 lines and 5 functions in DrDustinEdwards/capsid, at commit daa875b on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| windowKeys | 42 to 45 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| unavailable | 49 to 55 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| checkRate | 57 to 92 | 12 | 100% | WatchCRAP 12 | No data: mutation testing has not run on this repo yet |
| callerIp | 94 to 96 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| rateLimitedResponse | 100 to 114 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
Imports
It imports src/log.ts. Imported by src/access-sync.ts, src/dcr.ts, src/public-data.ts, src/routes.ts.
Source
import { logEvent } from "./log";
// Best-effort, not exact. Each check reads both KV counters, compares, then writes
// count + 1, with no compare-and-swap, so concurrent requests can read the same count
// and a burst can pass somewhat over perHour or perDay. KV is also eventually
// consistent across locations. A limit here bounds abuse in bulk; nothing may rely on
// it as an exact ceiling.
export interface RateLimitPolicy {
// Distinct per endpoint so one path cannot spend another's budget.
prefix: string;
perHour: number;
perDay: number;
label: string;
// What an unreadable counter means, decided per endpoint. Allowing on every KV
// failure gives one answer to two different questions, so the judgement is stated
// on the policy, next to the endpoint it is about, not in the limiter.
onUnavailable: "allow" | "refuse";
}
export const MAX_REPORTS_PER_HOUR = 300;
export const MAX_REPORTS_PER_DAY = 1000;
// Fail closed: /csp-report is unauthenticated and every accepted report writes log
// lines, so failing open would let anyone fill the logs during an outage. A dropped
// report costs one browser diagnostic, and the browser still gets its 204.
export const CSP_REPORT_LIMIT: RateLimitPolicy = {
prefix: "csp:rate:",
perHour: MAX_REPORTS_PER_HOUR,
perDay: MAX_REPORTS_PER_DAY,
label: "CSP_REPORT",
onUnavailable: "refuse",
};
// A refusal is a spent budget or an unreadable one ("unavailable"), and every reader
// must say which rather than print a count that was never measured.
export type RateRefusal =
| { allowed: false; window: "hour" | "day"; count: number; limit: number }
| { allowed: false; window: "unavailable"; detail: string };
export type RateVerdict = { allowed: true } | RateRefusal;
function windowKeys(prefix: string, ip: string, now: Date): { hour: string; day: string } {
const iso = now.toISOString();
return { hour: `${prefix}h:${ip}:${iso.slice(0, 13)}`, day: `${prefix}d:${ip}:${iso.slice(0, 10)}` };
}
// Every path that cannot read or advance a counter ends here, and the policy's
// `onUnavailable` decides. The log line is the only output on the allow side.
function unavailable(policy: RateLimitPolicy, ip: string, detail: string): RateVerdict {
const event = `${policy.label}_RATE_LIMIT_UNAVAILABLE`;
logEvent("error", event, {
message: `${event} ${detail} for ${ip}, ${policy.onUnavailable === "allow" ? "allowing" : "refusing"}`,
});
return policy.onUnavailable === "allow" ? { allowed: true } : { allowed: false, window: "unavailable", detail };
}
export async function checkRate(
kv: KVNamespace | undefined,
ip: string,
now: Date,
policy: RateLimitPolicy
): Promise<RateVerdict> {
// A deploy missing APP_KV, named apart from a read failure.
if (!kv) return unavailable(policy, ip, "no KV binding");
const keys = windowKeys(policy.prefix, ip, now);
let hourCount: number;
let dayCount: number;
try {
const [h, d] = await Promise.all([kv.get(keys.hour), kv.get(keys.day)]);
hourCount = Number(h ?? 0);
dayCount = Number(d ?? 0);
if (!Number.isFinite(hourCount) || !Number.isFinite(dayCount)) throw new Error("non-numeric counter");
} catch (err) {
return unavailable(policy, ip, `read failed: ${err instanceof Error ? err.message : String(err)}`);
}
// Refuse before the write, or retrying extends the block.
if (hourCount >= policy.perHour) return { allowed: false, window: "hour", count: hourCount, limit: policy.perHour };
if (dayCount >= policy.perDay) return { allowed: false, window: "day", count: dayCount, limit: policy.perDay };
try {
await Promise.all([
kv.put(keys.hour, String(hourCount + 1), { expirationTtl: 3600 }),
kv.put(keys.day, String(dayCount + 1), { expirationTtl: 86_400 }),
]);
} catch (err) {
// A counter that does not advance is no ceiling, so the policy decides.
return unavailable(policy, ip, `write failed: ${err instanceof Error ? err.message : String(err)}`);
}
return { allowed: true };
}
export function callerIp(request: Request): string {
return request.headers.get("CF-Connecting-IP") ?? "unknown";
}
// 429, not 204: a dropped report must not look stored. 503 when the rate could not
// be measured, which is a server problem. `noun` names what the endpoint counts.
export function rateLimitedResponse(verdict: RateRefusal, noun = "reports"): Response {
if (verdict.window === "unavailable") {
return new Response(`rate limiting is unavailable, so ${noun} are refused: ${verdict.detail}`, {
status: 503,
headers: { "Content-Type": "text/plain;charset=utf-8", "Retry-After": "60" },
});
}
return new Response(`too many ${noun}: ${verdict.count} in the last ${verdict.window}, limit ${verdict.limit}`, {
status: 429,
headers: {
"Content-Type": "text/plain;charset=utf-8",
"Retry-After": verdict.window === "hour" ? "3600" : "86400",
},
});
}