diff --git a/src/app/api/auth/check-user/__tests__/route.test.ts b/src/app/api/auth/check-user/__tests__/route.test.ts index 60b90f6f9..9c1a71389 100644 --- a/src/app/api/auth/check-user/__tests__/route.test.ts +++ b/src/app/api/auth/check-user/__tests__/route.test.ts @@ -31,6 +31,15 @@ vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi .fn() .mockResolvedValue({ allowed: true, remaining: 30, resetAt: 0 }), + // v1.4.43 W13 M-4 — `check-user` now routes through the auth-surface + // wrapper so the trust-violation branch can swap to a tighter global + // bucket. Default to a clean per-IP result. + checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 30, + resetAt: 0, + ip: "203.0.113.1", + }), rateLimitHeaders: vi.fn(() => ({})), })); @@ -72,7 +81,7 @@ describe("POST /api/auth/check-user — audit-log row (M-1)", () => { const args = vi.mocked(auditLog).mock.calls[0]; expect(args[0]).toBe("auth.check-user"); expect(args[1]).toEqual({ - ipAddress: "unknown", + ipAddress: "203.0.113.1", details: { branch: "not_found", identifier_hash: expectedHash("ghost@example.com"), @@ -99,7 +108,7 @@ describe("POST /api/auth/check-user — audit-log row (M-1)", () => { expect(auditLog).toHaveBeenCalledTimes(1); expect(vi.mocked(auditLog).mock.calls[0][1]).toEqual({ userId: "user-1", - ipAddress: "unknown", + ipAddress: "203.0.113.1", details: { branch: "passkey_only", identifier_hash: expectedHash("marc"), diff --git a/src/app/api/auth/check-user/route.ts b/src/app/api/auth/check-user/route.ts index 6591231b8..b0a63373a 100644 --- a/src/app/api/auth/check-user/route.ts +++ b/src/app/api/auth/check-user/route.ts @@ -37,12 +37,15 @@ */ import { z } from "zod/v4"; import { prisma } from "@/lib/db"; -import { apiSuccess, apiError, safeJson, getClientIp } from "@/lib/api-response"; +import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { hashToken } from "@/lib/auth/hmac"; -import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { + checkAuthSurfaceRateLimit, + rateLimitHeaders, +} from "@/lib/rate-limit"; import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; @@ -59,12 +62,14 @@ export type CheckUserBranch = | "exists"; export const POST = apiHandler(async (request: NextRequest) => { - const ip = getClientIp(request) ?? "unknown"; - const rl = await checkRateLimit( - `auth:check-user:${ip}`, + // v1.4.43 W13 M-4 — tighter shared bucket on trust-chain misconfig. + const rl = await checkAuthSurfaceRateLimit( + request, + "auth:check-user", 30, 15 * 60 * 1000, ); + const ip = rl.ip ?? "unknown"; if (!rl.allowed) { return NextResponse.json( { data: null, error: "Too many requests. Please try again later." }, diff --git a/src/app/api/auth/login/__tests__/native-token.test.ts b/src/app/api/auth/login/__tests__/native-token.test.ts index 5597ef3f2..65f2f809e 100644 --- a/src/app/api/auth/login/__tests__/native-token.test.ts +++ b/src/app/api/auth/login/__tests__/native-token.test.ts @@ -28,6 +28,15 @@ vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi .fn() .mockResolvedValue({ allowed: true, remaining: 5, reset: 0 }), + // v1.4.43 W13 M-4 — auth surfaces now route through the wrapper that + // returns `{ip, ...rate-limit-result}`. Default to a clean per-IP + // result so the existing tests don't need to set every IP up. + checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 5, + resetAt: 0, + ip: "203.0.113.1", + }), rateLimitHeaders: vi.fn(() => ({})), })); @@ -55,7 +64,11 @@ vi.mock("next/headers", () => ({ import { POST } from "../route"; import { prisma } from "@/lib/db"; import { verifyPassword } from "@/lib/auth/password"; -import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { + checkRateLimit, + checkAuthSurfaceRateLimit, + rateLimitHeaders, +} from "@/lib/rate-limit"; import { ensureDbCompatibility } from "@/lib/db-compat"; import { auditLog } from "@/lib/auth/audit"; import { createSession } from "@/lib/auth/session"; @@ -94,6 +107,12 @@ beforeEach(() => { remaining: 5, reset: 0, } as never); + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: true, + remaining: 5, + resetAt: 0, + ip: "203.0.113.1", + } as never); vi.mocked(rateLimitHeaders).mockReturnValue({} as never); vi.mocked(ensureDbCompatibility).mockResolvedValue(undefined); vi.mocked(auditLog).mockResolvedValue(undefined as never); diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 21108ffa6..49619ded4 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -6,10 +6,9 @@ import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError, - getClientIp, safeJson, } from "@/lib/api-response"; -import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { ensureDbCompatibility } from "@/lib/db-compat"; import { NextRequest, NextResponse } from "next/server"; import { apiHandler } from "@/lib/api-handler"; @@ -22,8 +21,16 @@ import { import { issueAccessAndRefresh } from "@/lib/auth/refresh-token"; export const POST = apiHandler(async (request: NextRequest) => { - const ip = getClientIp(request) ?? "unknown"; - const rl = await checkRateLimit(`auth:login:${ip}`, 5, 15 * 60 * 1000); + // v1.4.43 W13 M-4 — `checkAuthSurfaceRateLimit` swaps to a tighter + // global bucket when the trust chain is misconfigured; otherwise it + // is byte-equivalent to the previous per-IP `auth:login:{ip}` key. + const rl = await checkAuthSurfaceRateLimit( + request, + "auth:login", + 5, + 15 * 60 * 1000, + ); + const ip = rl.ip ?? "unknown"; if (!rl.allowed) { return NextResponse.json( { diff --git a/src/app/api/auth/passkey/login-options/route.ts b/src/app/api/auth/passkey/login-options/route.ts index c50cd0f2a..8a5342ed3 100644 --- a/src/app/api/auth/passkey/login-options/route.ts +++ b/src/app/api/auth/passkey/login-options/route.ts @@ -1,14 +1,18 @@ import { createAuthenticationOptions } from "@/lib/auth/passkey"; -import { apiSuccess, getClientIp } from "@/lib/api-response"; -import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { apiSuccess } from "@/lib/api-response"; +import { + checkAuthSurfaceRateLimit, + rateLimitHeaders, +} from "@/lib/rate-limit"; import { NextResponse } from "next/server"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; export const POST = apiHandler(async (request: Request) => { - const ip = getClientIp(request) ?? "unknown"; - const rl = await checkRateLimit( - `auth:passkey-login-options:${ip}`, + // v1.4.43 W13 M-4 — tighter shared bucket on trust-chain misconfig. + const rl = await checkAuthSurfaceRateLimit( + request, + "auth:passkey-login-options", 10, 15 * 60 * 1000, ); diff --git a/src/app/api/auth/passkey/login-verify/__tests__/native-token.test.ts b/src/app/api/auth/passkey/login-verify/__tests__/native-token.test.ts index b4f5f4030..18fad6ff4 100644 --- a/src/app/api/auth/passkey/login-verify/__tests__/native-token.test.ts +++ b/src/app/api/auth/passkey/login-verify/__tests__/native-token.test.ts @@ -24,6 +24,9 @@ vi.mock("@/lib/auth/audit", () => ({ vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn(), + // v1.4.43 W13 M-4 — passkey/login-verify now uses the auth-surface + // wrapper. Default to a clean per-IP result. + checkAuthSurfaceRateLimit: vi.fn(), })); vi.mock("@/lib/db-compat", () => ({ @@ -50,7 +53,10 @@ vi.mock("next/headers", () => ({ import { POST } from "../route"; import { prisma } from "@/lib/db"; import { verifyAuthentication } from "@/lib/auth/passkey"; -import { checkRateLimit } from "@/lib/rate-limit"; +import { + checkRateLimit, + checkAuthSurfaceRateLimit, +} from "@/lib/rate-limit"; const FAKE_USER = { id: "user-1", @@ -81,6 +87,12 @@ beforeEach(() => { remaining: 9, reset: 0, } as never); + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: true, + remaining: 9, + resetAt: 0, + ip: "203.0.113.1", + } as never); vi.mocked(verifyAuthentication).mockResolvedValue({ verification: { verified: true }, passkey: { userId: "user-1" }, diff --git a/src/app/api/auth/passkey/login-verify/route.ts b/src/app/api/auth/passkey/login-verify/route.ts index bac870ed7..041cf3b90 100644 --- a/src/app/api/auth/passkey/login-verify/route.ts +++ b/src/app/api/auth/passkey/login-verify/route.ts @@ -4,12 +4,11 @@ import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError, - getClientIp, safeJson, } from "@/lib/api-response"; import { prisma } from "@/lib/db"; import { ensureDbCompatibility } from "@/lib/db-compat"; -import { checkRateLimit } from "@/lib/rate-limit"; +import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; import { NextRequest } from "next/server"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; @@ -23,12 +22,14 @@ import { issueAccessAndRefresh } from "@/lib/auth/refresh-token"; export const POST = apiHandler(async (request: NextRequest) => { await ensureDbCompatibility(); - const ip = getClientIp(request); - const rl = await checkRateLimit( - `auth:passkey-verify:${ip}`, + // v1.4.43 W13 M-4 — tighter shared bucket on trust-chain misconfig. + const rl = await checkAuthSurfaceRateLimit( + request, + "auth:passkey-verify", 10, 15 * 60 * 1000, ); + const ip = rl.ip; if (!rl.allowed) { return apiError("Too many attempts. Please wait 15 minutes.", 429); } diff --git a/src/app/api/auth/refresh/route.ts b/src/app/api/auth/refresh/route.ts index be698f106..cb3256813 100644 --- a/src/app/api/auth/refresh/route.ts +++ b/src/app/api/auth/refresh/route.ts @@ -7,12 +7,11 @@ import { apiHandler } from "@/lib/api-handler"; import { apiSuccess, apiError, - getClientIp, safeJson, } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { annotate } from "@/lib/logging/context"; -import { checkRateLimit } from "@/lib/rate-limit"; +import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; import { resolveTokenPolicy } from "@/lib/auth/native-client"; import { rotateRefreshToken, @@ -20,8 +19,14 @@ import { } from "@/lib/auth/refresh-token"; export const POST = apiHandler(async (request: NextRequest) => { - const ip = getClientIp(request) ?? "unknown"; - const rl = await checkRateLimit(`auth:refresh:${ip}`, 60, 15 * 60 * 1000); + // v1.4.43 W13 M-4 — tighter shared bucket on trust-chain misconfig. + const rl = await checkAuthSurfaceRateLimit( + request, + "auth:refresh", + 60, + 15 * 60 * 1000, + ); + const ip = rl.ip ?? "unknown"; if (!rl.allowed) { return apiError("Too many refresh attempts. Please retry later.", 429); } diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index a4d35ef90..26cfbd608 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -6,10 +6,9 @@ import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError, - getClientIp, safeJson, } from "@/lib/api-response"; -import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { ensureDbCompatibility } from "@/lib/db-compat"; import { NextRequest, NextResponse } from "next/server"; import { apiHandler } from "@/lib/api-handler"; @@ -21,8 +20,15 @@ import { } from "@/lib/tz/resolver"; export const POST = apiHandler(async (request: NextRequest) => { - const ip = getClientIp(request) ?? "unknown"; - const rl = await checkRateLimit(`auth:register:${ip}`, 5, 15 * 60 * 1000); + // v1.4.43 W13 M-4 — tighten to a global bucket when the trust chain + // is misconfigured; otherwise byte-equivalent per-IP semantics. + const rl = await checkAuthSurfaceRateLimit( + request, + "auth:register", + 5, + 15 * 60 * 1000, + ); + const ip = rl.ip ?? "unknown"; if (!rl.allowed) { return NextResponse.json( { diff --git a/src/lib/__tests__/rate-limit-auth-surface.test.ts b/src/lib/__tests__/rate-limit-auth-surface.test.ts new file mode 100644 index 000000000..25558cae5 --- /dev/null +++ b/src/lib/__tests__/rate-limit-auth-surface.test.ts @@ -0,0 +1,213 @@ +/** + * v1.4.43 W13 M-4 — `checkAuthSurfaceRateLimit` is the wrapper every + * anonymous auth surface routes through. It guards against the + * trust-chain misconfiguration documented in `api-response.ts`: + * + * - Clean chain → per-IP bucket `{prefix}:{ip}` with the caller's limit. + * - Broken chain (`trustViolation === true`) → tight global bucket + * `auth:anon:trust-violation` shared across every auth surface, + * capped at 100/15min total. One attacker can no longer exhaust the + * per-surface "unknown" bucket and lock every other anonymous caller + * out of login + register + passkey-verify + … + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { + checkRateLimit as realCheckRateLimit, + checkAuthSurfaceRateLimit, +} from "../rate-limit"; +import { _resetTrustViolationWarningForTests } from "../api-response"; + +vi.mock("@/lib/db", () => ({ + prisma: { + $queryRaw: vi.fn(), + }, +})); + +import { prisma } from "@/lib/db"; + +const ORIGINAL_HOPS = process.env.TRUST_PROXY_HOPS; + +beforeEach(() => { + vi.mocked(prisma.$queryRaw).mockReset(); + vi.mocked(prisma.$queryRaw).mockResolvedValue([ + { count: 1, reset_at: new Date(Date.now() + 60_000) }, + ] as never); + _resetTrustViolationWarningForTests(); + delete process.env.TRUST_PROXY_HOPS; +}); + +afterEach(() => { + if (ORIGINAL_HOPS === undefined) delete process.env.TRUST_PROXY_HOPS; + else process.env.TRUST_PROXY_HOPS = ORIGINAL_HOPS; +}); + +function makeRequest(headers: Record): Request { + return new Request("https://example.com/api/auth/login", { headers }); +} + +/** + * Pull the rate-limit key passed to `prisma.$queryRaw` by walking the + * tagged-template args. `$queryRaw\`INSERT … VALUES (${key}, …)\`` lands + * as `[stringsArray, key, windowInterval]`. The key is the first + * substitution argument. + */ +function lastKey(): string { + const calls = vi.mocked(prisma.$queryRaw).mock.calls; + const last = calls[calls.length - 1]; + // First substitution is the bucket key — index 1 of the call arguments. + return last[1] as string; +} + +/** + * Pull the limit passed to the underlying `checkRateLimit`. The wrapper + * itself doesn't expose it directly; we re-derive by counting the calls + * with the tight key and checking that the returned `allowed` flag flips + * at the documented 100/15min cap below. + */ +function lastWindowMs(): string { + const calls = vi.mocked(prisma.$queryRaw).mock.calls; + const last = calls[calls.length - 1]; + return last[2] as string; +} + +describe("checkAuthSurfaceRateLimit — per-IP path", () => { + it("routes to {prefix}:{ip} when the trust chain is clean", async () => { + // Default TRUST_PROXY_HOPS=1, single XFF entry → clean chain. + const result = await checkAuthSurfaceRateLimit( + makeRequest({ "x-forwarded-for": "203.0.113.5" }), + "auth:login", + 5, + 15 * 60 * 1000, + ); + expect(result.ip).toBe("203.0.113.5"); + expect(result.allowed).toBe(true); + expect(lastKey()).toBe("auth:login:203.0.113.5"); + expect(lastWindowMs()).toBe(`${15 * 60 * 1000} milliseconds`); + }); + + it("falls back to {prefix}:unknown when getClientIp returns null but trustViolation is false", async () => { + // No XFF + no x-real-ip + no CF → null ip, no violation (no chain to violate). + const result = await checkAuthSurfaceRateLimit( + makeRequest({}), + "auth:register", + 5, + 15 * 60 * 1000, + ); + expect(result.ip).toBeNull(); + expect(lastKey()).toBe("auth:register:unknown"); + }); + + it("returns the underlying RateLimitResult plus the resolved ip", async () => { + vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([ + { count: 6, reset_at: new Date(Date.now() + 60_000) }, + ] as never); + const result = await checkAuthSurfaceRateLimit( + makeRequest({ "x-forwarded-for": "203.0.113.5" }), + "auth:login", + 5, + 15 * 60 * 1000, + ); + expect(result.allowed).toBe(false); + expect(result.remaining).toBe(0); + expect(result.ip).toBe("203.0.113.5"); + }); +}); + +describe("checkAuthSurfaceRateLimit — trust-violation tightening", () => { + beforeEach(() => { + // TRUST_PROXY_HOPS=2 with a 1-entry chain → trust violation. + process.env.TRUST_PROXY_HOPS = "2"; + }); + + it("routes every login attempt under a violation into the global bucket", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + await checkAuthSurfaceRateLimit( + makeRequest({ "x-forwarded-for": "5.6.7.8" }), + "auth:login", + 5, + 15 * 60 * 1000, + ); + expect(lastKey()).toBe("auth:anon:trust-violation"); + // Window matches the 15-minute global cap, NOT the caller's window. + expect(lastWindowMs()).toBe(`${15 * 60 * 1000} milliseconds`); + } finally { + warn.mockRestore(); + } + }); + + it("routes every auth surface (login + register + passkey-*) into the SAME bucket under violation", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const surfaces: Array<[string, number]> = [ + ["auth:login", 5], + ["auth:register", 5], + ["auth:passkey-login-options", 10], + ["auth:passkey-verify", 10], + ["auth:refresh", 60], + ["auth:check-user", 30], + ]; + const seenKeys = new Set(); + for (const [prefix, limit] of surfaces) { + await checkAuthSurfaceRateLimit( + makeRequest({ "x-forwarded-for": "5.6.7.8" }), + prefix, + limit, + 15 * 60 * 1000, + ); + seenKeys.add(lastKey()); + } + // Every surface collapsed to the single tight bucket. + expect(seenKeys).toEqual(new Set(["auth:anon:trust-violation"])); + } finally { + warn.mockRestore(); + } + }); + + it("returns ip from the helper even when the tightened bucket fires (so call sites can audit-log the same IP)", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const result = await checkAuthSurfaceRateLimit( + makeRequest({ + "x-forwarded-for": "5.6.7.8", + "x-real-ip": "9.9.9.9", + }), + "auth:login", + 5, + 15 * 60 * 1000, + ); + // Falls through to x-real-ip when the chain is too short. + expect(result.ip).toBe("9.9.9.9"); + expect(lastKey()).toBe("auth:anon:trust-violation"); + } finally { + warn.mockRestore(); + } + }); + + it("flips to denied once the global cap is hit (count > 100)", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([ + { count: 101, reset_at: new Date(Date.now() + 60_000) }, + ] as never); + const result = await checkAuthSurfaceRateLimit( + makeRequest({ "x-forwarded-for": "5.6.7.8" }), + "auth:login", + 5, + 15 * 60 * 1000, + ); + expect(result.allowed).toBe(false); + expect(lastKey()).toBe("auth:anon:trust-violation"); + } finally { + warn.mockRestore(); + } + }); +}); + +describe("checkRateLimit (existing semantics — regression guard)", () => { + it("still creates the per-key bucket via the unchanged SQL path", async () => { + await realCheckRateLimit("custom:key:1.2.3.4", 5, 60_000); + expect(lastKey()).toBe("custom:key:1.2.3.4"); + }); +}); diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index e336099b0..4ee45cac4 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -4,6 +4,7 @@ */ import { prisma } from "@/lib/db"; +import { getClientIpOrTrustWarning } from "@/lib/api-response"; export interface RateLimitResult { allowed: boolean; @@ -62,3 +63,65 @@ export function rateLimitHeaders( "X-RateLimit-Reset": new Date(result.resetAt).toISOString(), }; } + +/** + * v1.4.43 W13 M-4 — anonymous/trust-violation bucket key shared by every + * caller of `checkAuthSurfaceRateLimit`. When `TRUST_PROXY_HOPS` and the + * actual proxy chain don't agree, `getClientIp` returns null and every + * caller falls back to a literal `"unknown"` rate-limit bucket. Any + * attacker can then exhaust the `auth:login:unknown` bucket and lock + * every other legitimate anonymous caller out of the surface. + * + * The tighter bucket is shared across every auth surface — a misconfigured + * deployment with the trust chain broken accepts at most this many + * anonymous attempts across login + register + passkey-verify + … per + * window. Operators see the once-per-process `console.warn` from + * `getClientIpOrTrustWarning` and fix the proxy chain; the tight bucket + * caps the blast radius while they do. + */ +const TIGHT_ANON_KEY = "auth:anon:trust-violation"; +const TIGHT_ANON_LIMIT = 100; +const TIGHT_ANON_WINDOW_MS = 15 * 60 * 1000; + +/** + * Rate-limit helper for anonymous auth surfaces (login, register, + * passkey-login-{options,verify}, refresh, check-user). + * + * Per-IP path: when the trust chain resolves cleanly, route the request + * to a bucket keyed by `{prefix}:{ip ?? "unknown"}` with the caller's + * supplied limit + window. Existing semantics, byte-for-byte. + * + * Tightened path: when `getClientIpOrTrustWarning` reports + * `trustViolation === true`, the configured proxy hops and the actual + * chain length don't match. Every anonymous caller already shares a + * single `"unknown"` bucket on the per-IP path; this helper instead + * routes them to a fixed global bucket with a strict 100/15min cap. + * One attacker can no longer exhaust the per-surface bucket — they hit + * the global cap first, and every other anonymous caller on any auth + * surface shares the same cap. + * + * The returned shape matches `checkRateLimit` so call sites can attach + * `rateLimitHeaders(rl)` unchanged. + */ +export async function checkAuthSurfaceRateLimit( + request: Request, + prefix: string, + perIpLimit: number, + windowMs: number, +): Promise { + const { ip, trustViolation } = getClientIpOrTrustWarning(request); + if (trustViolation) { + const result = await checkRateLimit( + TIGHT_ANON_KEY, + TIGHT_ANON_LIMIT, + TIGHT_ANON_WINDOW_MS, + ); + return { ...result, ip }; + } + const result = await checkRateLimit( + `${prefix}:${ip ?? "unknown"}`, + perIpLimit, + windowMs, + ); + return { ...result, ip }; +}