feat(rate-limit): tighten anonymous bucket on trust-chain misconfig

v1.4.43 W13 M-4 — every IP-keyed auth surface (login, register,
passkey/login-options, passkey/login-verify, refresh, check-user)
falls back to a literal `{prefix}:unknown` rate-limit bucket when
`getClientIp` returns null. A misconfigured `TRUST_PROXY_HOPS`
silently degrades precision for the rest of the process lifetime
(the warning fires once); one attacker exhausting the
`auth:login:unknown` bucket then locks every other anonymous
caller out of the surface.

The `checkAuthSurfaceRateLimit` wrapper consults
`getClientIpOrTrustWarning`: clean chain → existing per-IP
semantics, byte-equivalent; `trustViolation === true` → routes
to a single global `auth:anon:trust-violation` bucket capped at
100/15min across every auth surface. The blast radius caps while
the operator fixes the proxy chain.

Auth-surface routes converted to the wrapper:
- /api/auth/login (5/15min per IP, 100/15min global on violation)
- /api/auth/register (5/15min)
- /api/auth/passkey/login-options (10/15min)
- /api/auth/passkey/login-verify (10/15min)
- /api/auth/refresh (60/15min)
- /api/auth/check-user (30/15min)

/api/auth/password is keyed by `user.id` (already authenticated)
so it stays on `checkRateLimit` directly.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 22:19:57 +02:00
1 parent 81b1eeea8d
commit d324d5162b
11 files changed
+375 -31

No files matched your search

@@ -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"),
+10 -5
View File
@@ -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." },
@@ -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);
+11 -4
View File
@@ -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(
{
@@ -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,
);
@@ -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" },
@@ -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);
}
+9 -4
View File
@@ -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);
}
+10 -4
View File
@@ -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(
{
@@ -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<string, string>): 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<string>();
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");
});
});
+63
View File
@@ -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<RateLimitResult & { ip: string | null }> {
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 };
}