Files
Calbook/lib/rate-limit.ts

171 lines
4.0 KiB
TypeScript

import { isIP } from "net";
type RateLimitEntry = {
count: number;
resetAt: number;
};
type HeaderBag = Headers | Record<string, string | string[] | undefined>;
type EnforceRateLimitInput = {
req: Request;
scope: string;
limit: number;
windowMs: number;
keySuffix?: string;
};
type RateLimitResult = {
ok: boolean;
remaining: number;
retryAfterSeconds: number;
};
declare global {
// eslint-disable-next-line no-var
var calbookRateLimitStore: Map<string, RateLimitEntry> | undefined;
}
const store = global.calbookRateLimitStore ?? new Map<string, RateLimitEntry>();
if (!global.calbookRateLimitStore) {
global.calbookRateLimitStore = store;
}
const CLEANUP_EVERY = 200;
let writes = 0;
function nowMs() {
return Date.now();
}
function cleanupExpired() {
const now = nowMs();
for (const [key, value] of store.entries()) {
if (value.resetAt <= now) {
store.delete(key);
}
}
}
function readHeader(headers: HeaderBag, key: string): string | undefined {
if (headers instanceof Headers) {
return headers.get(key) ?? undefined;
}
const value = headers[key] ?? headers[key.toLowerCase()] ?? headers[key.toUpperCase()];
if (Array.isArray(value)) return value[0];
return value ?? undefined;
}
function trustProxyHeaders() {
const raw = (process.env.TRUST_PROXY_HEADERS ?? "").trim().toLowerCase();
return raw === "1" || raw === "true" || raw === "yes";
}
function normalizeIpCandidate(value: string): string | null {
let candidate = value.trim().replace(/^for=/i, "").replace(/^"|"$/g, "");
if (!candidate) return null;
if (candidate.startsWith("[")) {
const end = candidate.indexOf("]");
if (end > 1) {
candidate = candidate.slice(1, end);
}
}
if (candidate.includes("%")) {
candidate = candidate.split("%")[0] ?? candidate;
}
if (candidate.includes(".") && candidate.includes(":") && isIP(candidate) === 0) {
const withoutPort = candidate.split(":")[0];
if (withoutPort) {
candidate = withoutPort;
}
}
return isIP(candidate) ? candidate : null;
}
export function getClientIpFromHeaders(headers: HeaderBag): string {
if (!trustProxyHeaders()) {
return "proxy-untrusted";
}
const forwarded = readHeader(headers, "x-forwarded-for");
if (forwarded) {
const parsed = forwarded
.split(",")
.map((part) => normalizeIpCandidate(part))
.find((value): value is string => Boolean(value));
if (parsed) return parsed;
}
const realIp = readHeader(headers, "x-real-ip");
if (realIp) {
const parsed = normalizeIpCandidate(realIp);
if (parsed) return parsed;
}
const cfIp = readHeader(headers, "cf-connecting-ip");
if (cfIp) {
const parsed = normalizeIpCandidate(cfIp);
if (parsed) return parsed;
}
return "unknown";
}
export function getClientIp(req: Request): string {
return getClientIpFromHeaders(req.headers);
}
export function consumeRateLimit(
key: string,
limit: number,
windowMs: number
): RateLimitResult {
writes += 1;
if (writes % CLEANUP_EVERY === 0) {
cleanupExpired();
}
const now = nowMs();
const existing = store.get(key);
if (!existing || existing.resetAt <= now) {
store.set(key, {
count: 1,
resetAt: now + windowMs
});
return {
ok: true,
remaining: Math.max(0, limit - 1),
retryAfterSeconds: Math.ceil(windowMs / 1000)
};
}
existing.count += 1;
store.set(key, existing);
if (existing.count > limit) {
return {
ok: false,
remaining: 0,
retryAfterSeconds: Math.max(1, Math.ceil((existing.resetAt - now) / 1000))
};
}
return {
ok: true,
remaining: Math.max(0, limit - existing.count),
retryAfterSeconds: Math.max(1, Math.ceil((existing.resetAt - now) / 1000))
};
}
export function enforceRateLimit(input: EnforceRateLimitInput): RateLimitResult {
const ip = getClientIp(input.req);
const suffix = input.keySuffix ? `:${input.keySuffix}` : "";
const key = `${input.scope}:${ip}${suffix}`;
return consumeRateLimit(key, input.limit, input.windowMs);
}