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); }