46 lines
2.2 KiB
TypeScript
46 lines
2.2 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { isIP } from "node:net";
|
|
|
|
import { getRedisClient } from "@/lib/redis/client";
|
|
import { HttpError } from "./http";
|
|
|
|
const consumeScript = `
|
|
local count = redis.call('INCR', KEYS[1])
|
|
if count == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
|
|
local ttl = redis.call('PTTL', KEYS[1])
|
|
if ttl < 0 then redis.call('PEXPIRE', KEYS[1], ARGV[1]); ttl = tonumber(ARGV[1]) end
|
|
return {count, ttl}
|
|
`;
|
|
|
|
export function trustedClientAddress(headers: Headers): string {
|
|
// Only enable this after the origin is restricted to the sanitizing proxy.
|
|
const header = process.env.TRUSTED_CLIENT_IP_HEADER;
|
|
const address = header ? headers.get(header)?.trim() : undefined;
|
|
if (!address || address.includes("%") || !isIP(address)) return "unknown";
|
|
if (isIP(address) === 4) return address;
|
|
// URL normalizes alternate IPv6 spellings. Collapse residential /64s.
|
|
const normalized = new URL(`http://[${address}]/`).hostname.slice(1, -1);
|
|
if (normalized.startsWith("::ffff:")) {
|
|
const groups = normalized.slice(7).split(":").map((part) => parseInt(part, 16));
|
|
return `${groups[0] >> 8}.${groups[0] & 255}.${groups[1] >> 8}.${groups[1] & 255}`;
|
|
}
|
|
const [left, right = ""] = normalized.split("::");
|
|
const first = left ? left.split(":") : [];
|
|
const last = right ? right.split(":") : [];
|
|
const groups = [...first, ...Array(8 - first.length - last.length).fill("0"), ...last];
|
|
return `${groups.slice(0, 4).join(":")}/64`;
|
|
}
|
|
|
|
export async function consumeRateLimit(key: string, rule: { window: number; max: number }) {
|
|
const digest = createHash("sha256").update(key).digest("hex");
|
|
const redis = await getRedisClient();
|
|
const result = await redis.eval(consumeScript, 1,
|
|
`${process.env.REDIS_SECURITY_PREFIX || "buzz:security"}:rate:${digest}`, rule.window * 1000) as [number, number];
|
|
return { allowed: result[0] <= rule.max, retryAfter: result[0] <= rule.max ? null : Math.max(1, Math.ceil(result[1] / 1000)) };
|
|
}
|
|
|
|
export async function limitRequest(scope: string, identity: string, max: number, window = 60) {
|
|
const result = await consumeRateLimit(`${scope}:${identity}`, { window, max });
|
|
if (!result.allowed) throw new HttpError(429, "too-many-requests", result.retryAfter ?? window);
|
|
}
|