Files
buzz-sheet/lib/security/rate-limit.ts
T
gunshiz 87e6bcd96f
CI / Verify and audit (push) Successful in 2m33s
CI / Build, scan and deploy immutable images (push) Failing after 1m29s
feat : 6 astra improve it
2026-09-22 18:28:18 +07:00

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