mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
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"),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user