Phase 7
This commit is contained in:
189 files changed
+9873
-655
No files matched your search
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
import { checkRateLimit } from "../rate-limit";
|
||||
|
||||
describe("checkRateLimit", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("allows requests under the limit", () => {
|
||||
const key = `test:${crypto.randomUUID()}`;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const result = checkRateLimit(key, { maxAttempts: 3, windowMs: 60_000 });
|
||||
expect(result.allowed).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks the request once the limit is exceeded within the window", () => {
|
||||
const key = `test:${crypto.randomUUID()}`;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
checkRateLimit(key, { maxAttempts: 3, windowMs: 60_000 });
|
||||
}
|
||||
const blocked = checkRateLimit(key, { maxAttempts: 3, windowMs: 60_000 });
|
||||
expect(blocked.allowed).toBe(false);
|
||||
expect(blocked.retryAfterSeconds).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("tracks each key independently", () => {
|
||||
const keyA = `test:${crypto.randomUUID()}`;
|
||||
const keyB = `test:${crypto.randomUUID()}`;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
checkRateLimit(keyA, { maxAttempts: 3, windowMs: 60_000 });
|
||||
}
|
||||
// keyA is now exhausted, but keyB has never been touched.
|
||||
expect(checkRateLimit(keyA, { maxAttempts: 3, windowMs: 60_000 }).allowed).toBe(false);
|
||||
expect(checkRateLimit(keyB, { maxAttempts: 3, windowMs: 60_000 }).allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("resets once the window elapses", () => {
|
||||
vi.useFakeTimers();
|
||||
const key = `test:${crypto.randomUUID()}`;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
checkRateLimit(key, { maxAttempts: 2, windowMs: 1_000 });
|
||||
}
|
||||
expect(checkRateLimit(key, { maxAttempts: 2, windowMs: 1_000 }).allowed).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(1_001);
|
||||
|
||||
expect(checkRateLimit(key, { maxAttempts: 2, windowMs: 1_000 }).allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* In-memory, single-process rate limiting — appropriate for this app's
|
||||
* deployment shape (one Node.js server, no Redis/shared cache in the
|
||||
* stack; see docs/implementation-plan.md). Explicit trade-off: a
|
||||
* multi-instance deployment would need a shared store instead, since each
|
||||
* instance would otherwise track its own counters. Fixed-window counter
|
||||
* per key, not sliding-window — simpler, and the imprecision at window
|
||||
* boundaries doesn't matter for the abuse patterns this defends against
|
||||
* (credential stuffing, registration/booking spam).
|
||||
*/
|
||||
|
||||
interface WindowState {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
const buckets = new Map<string, WindowState>();
|
||||
|
||||
// Bounds memory growth from an unbounded set of keys (e.g. one per
|
||||
// attacker-controlled IP/email) — a lazy sweep on every call is cheap
|
||||
// relative to the rest of the request and needs no background timer.
|
||||
const MAX_TRACKED_KEYS = 50_000;
|
||||
|
||||
function sweepExpired(now: number): void {
|
||||
if (buckets.size < MAX_TRACKED_KEYS) return;
|
||||
for (const [key, state] of buckets) {
|
||||
if (state.resetAt <= now) buckets.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export interface RateLimitResult {
|
||||
allowed: boolean;
|
||||
retryAfterSeconds: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* `key` should already encode the thing being limited (e.g.
|
||||
* `"signin:alex@example.com"`, `"register:203.0.113.4"`) — this module
|
||||
* has no concept of the caller's domain, just fixed-window counters.
|
||||
*/
|
||||
export function checkRateLimit(
|
||||
key: string,
|
||||
params: { maxAttempts: number; windowMs: number },
|
||||
): RateLimitResult {
|
||||
const now = Date.now();
|
||||
sweepExpired(now);
|
||||
|
||||
const existing = buckets.get(key);
|
||||
if (!existing || existing.resetAt <= now) {
|
||||
buckets.set(key, { count: 1, resetAt: now + params.windowMs });
|
||||
return { allowed: true, retryAfterSeconds: 0 };
|
||||
}
|
||||
|
||||
if (existing.count >= params.maxAttempts) {
|
||||
return { allowed: false, retryAfterSeconds: Math.ceil((existing.resetAt - now) / 1000) };
|
||||
}
|
||||
|
||||
existing.count += 1;
|
||||
return { allowed: true, retryAfterSeconds: 0 };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { headers } from "next/headers";
|
||||
|
||||
/**
|
||||
* Best-effort caller IP for rate-limiting Server Actions, which (unlike
|
||||
* Route Handlers) never receive a `Request` object directly. Reads the
|
||||
* headers a reverse proxy/hosting platform sets — `x-forwarded-for` first
|
||||
* (its first entry is the original client, per the standard convention),
|
||||
* falling back to `x-real-ip`. Returns a constant placeholder if neither
|
||||
* is present (e.g. a direct connection with no proxy in front, or a test
|
||||
* environment) — that collapses everyone onto one shared bucket rather
|
||||
* than silently disabling the limit, which is the safer failure mode.
|
||||
*/
|
||||
export async function getRequestIp(): Promise<string> {
|
||||
const headerList = await headers();
|
||||
const forwardedFor = headerList.get("x-forwarded-for");
|
||||
if (forwardedFor) return forwardedFor.split(",")[0]!.trim();
|
||||
|
||||
const realIp = headerList.get("x-real-ip");
|
||||
if (realIp) return realIp.trim();
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
Reference in new issue
Block a user