Dead code: 77 verified-unused exports, files (BackArrow, MenuBars, cookies.ts, db/create.ts, schemas/comment.ts, security-headers.ts) and 12 unused dependencies removed Comments: ~370 RESTATE comments stripped across 53 files; 2 verbose blocks tightened; dead commented-out config removed Complexity: bulkUpsert extracted into 11 per-entity helpers (CCN 156->~10); login formHandler split into 3 submitters (CCN 63->~5); account page render split into 8 section components (CCN 40); updatePost SQL builder rebuilt; assert*Owned consolidated behind generic assertOwnedBy Defensive guards: 4 redundant rethrow/nullish guards removed
571 lines
18 KiB
TypeScript
571 lines
18 KiB
TypeScript
/**
|
|
* Rate Limiting Tests
|
|
* Tests for rate limiting mechanisms on authentication endpoints
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
import {
|
|
checkRateLimit,
|
|
getClientIP,
|
|
rateLimitLogin,
|
|
rateLimitPasswordReset,
|
|
rateLimitRegistration,
|
|
rateLimitEmailVerification,
|
|
clearRateLimitStore,
|
|
clearRateLimitLocalCache,
|
|
RATE_LIMITS
|
|
} from "~/server/security";
|
|
import { createMockEvent, randomIP } from "./test-utils";
|
|
import { TRPCError } from "@trpc/server";
|
|
|
|
/**
|
|
* Unique identifier helper — Date.now() alone collides when tests run within
|
|
* the same millisecond, which leaks state between tests. Appending randomness
|
|
* keeps each test's bucket isolated.
|
|
*/
|
|
let idCounter = 0;
|
|
function uniqueId(prefix = "test"): string {
|
|
idCounter += 1;
|
|
return `${prefix}-${Date.now()}-${idCounter}-${Math.random()
|
|
.toString(36)
|
|
.slice(2, 8)}`;
|
|
}
|
|
|
|
describe("Rate Limiting", () => {
|
|
// Clear rate limit store before each test to ensure isolation. MUST be
|
|
// awaited — clearRateLimitStore is async (DB round-trip) and an un-awaited
|
|
// clear lets leftover rows race the next test's atomic upsert.
|
|
beforeEach(async () => {
|
|
await clearRateLimitStore();
|
|
});
|
|
|
|
describe("checkRateLimit", () => {
|
|
it("should allow requests within rate limit", async () => {
|
|
const identifier = uniqueId();
|
|
const maxAttempts = 5;
|
|
const windowMs = 60000;
|
|
|
|
for (let i = 0; i < maxAttempts; i++) {
|
|
const remaining = await checkRateLimit(
|
|
identifier,
|
|
maxAttempts,
|
|
windowMs
|
|
);
|
|
expect(remaining).toBe(maxAttempts - i - 1);
|
|
}
|
|
});
|
|
|
|
it("should block requests exceeding rate limit", async () => {
|
|
const identifier = uniqueId();
|
|
const maxAttempts = 3;
|
|
const windowMs = 60000;
|
|
|
|
for (let i = 0; i < maxAttempts; i++) {
|
|
await checkRateLimit(identifier, maxAttempts, windowMs);
|
|
}
|
|
|
|
try {
|
|
await checkRateLimit(identifier, maxAttempts, windowMs);
|
|
expect.unreachable("Should have thrown");
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(TRPCError);
|
|
}
|
|
});
|
|
|
|
it("should include remaining time in error message", async () => {
|
|
const identifier = uniqueId();
|
|
const maxAttempts = 2;
|
|
const windowMs = 60000;
|
|
|
|
await checkRateLimit(identifier, maxAttempts, windowMs);
|
|
await checkRateLimit(identifier, maxAttempts, windowMs);
|
|
|
|
try {
|
|
await checkRateLimit(identifier, maxAttempts, windowMs);
|
|
expect.unreachable("Should have thrown TRPCError");
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(TRPCError);
|
|
const trpcError = error as TRPCError;
|
|
expect(trpcError.code).toBe("TOO_MANY_REQUESTS");
|
|
expect(trpcError.message).toMatch(/Try again in \d+ seconds/);
|
|
}
|
|
});
|
|
|
|
it("should reset after time window expires", async () => {
|
|
const identifier = uniqueId();
|
|
const maxAttempts = 3;
|
|
const windowMs = 500; // 500ms window for testing
|
|
|
|
for (let i = 0; i < maxAttempts; i++) {
|
|
await checkRateLimit(identifier, maxAttempts, windowMs);
|
|
}
|
|
|
|
try {
|
|
await checkRateLimit(identifier, maxAttempts, windowMs);
|
|
expect.unreachable("Should have thrown");
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(TRPCError);
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 600));
|
|
|
|
const remaining = await checkRateLimit(identifier, maxAttempts, windowMs);
|
|
expect(remaining).toBe(maxAttempts - 1);
|
|
});
|
|
|
|
it("should handle concurrent requests correctly", async () => {
|
|
const identifier = uniqueId();
|
|
const maxAttempts = 10;
|
|
const windowMs = 60000;
|
|
|
|
const results: number[] = [];
|
|
for (let i = 0; i < maxAttempts; i++) {
|
|
results.push(await checkRateLimit(identifier, maxAttempts, windowMs));
|
|
}
|
|
|
|
expect(results).toEqual([9, 8, 7, 6, 5, 4, 3, 2, 1, 0]);
|
|
});
|
|
|
|
it("should isolate different identifiers", async () => {
|
|
const maxAttempts = 3;
|
|
const windowMs = 60000;
|
|
|
|
const id1 = uniqueId("test1");
|
|
const id2 = uniqueId("test2");
|
|
|
|
for (let i = 0; i < maxAttempts; i++) {
|
|
await checkRateLimit(id1, maxAttempts, windowMs);
|
|
}
|
|
|
|
try {
|
|
await checkRateLimit(id1, maxAttempts, windowMs);
|
|
expect.unreachable("Should have thrown");
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(TRPCError);
|
|
}
|
|
|
|
const remaining = await checkRateLimit(id2, maxAttempts, windowMs);
|
|
expect(remaining).toBe(maxAttempts - 1);
|
|
});
|
|
});
|
|
|
|
describe("getClientIP", () => {
|
|
it("should extract IP from x-forwarded-for header", () => {
|
|
const event = createMockEvent({
|
|
headers: { "x-forwarded-for": "192.168.1.1, 10.0.0.1" }
|
|
});
|
|
|
|
const ip = getClientIP(event);
|
|
expect(ip).toBe("192.168.1.1");
|
|
});
|
|
|
|
it("should extract IP from x-real-ip header", () => {
|
|
const event = createMockEvent({
|
|
headers: { "x-real-ip": "192.168.1.2" }
|
|
});
|
|
|
|
const ip = getClientIP(event);
|
|
expect(ip).toBe("192.168.1.2");
|
|
});
|
|
|
|
it("should prefer x-forwarded-for over x-real-ip", () => {
|
|
const event = createMockEvent({
|
|
headers: {
|
|
"x-forwarded-for": "192.168.1.1",
|
|
"x-real-ip": "192.168.1.2"
|
|
}
|
|
});
|
|
|
|
const ip = getClientIP(event);
|
|
expect(ip).toBe("192.168.1.1");
|
|
});
|
|
|
|
it("should return unknown when no IP headers present", () => {
|
|
const event = createMockEvent({});
|
|
const ip = getClientIP(event);
|
|
expect(ip).toBe("unknown");
|
|
});
|
|
|
|
it("should trim whitespace from IP addresses", () => {
|
|
const event = createMockEvent({
|
|
headers: { "x-forwarded-for": " 192.168.1.1 , 10.0.0.1" }
|
|
});
|
|
|
|
const ip = getClientIP(event);
|
|
expect(ip).toBe("192.168.1.1");
|
|
});
|
|
|
|
it("should handle IPv6 addresses", () => {
|
|
const event = createMockEvent({
|
|
headers: {
|
|
"x-forwarded-for": "2001:0db8:85a3:0000:0000:8a2e:0370:7334"
|
|
}
|
|
});
|
|
|
|
const ip = getClientIP(event);
|
|
expect(ip).toBe("2001:0db8:85a3:0000:0000:8a2e:0370:7334");
|
|
});
|
|
});
|
|
|
|
describe("rateLimitLogin", () => {
|
|
it("should enforce email rate limits in test/dev environments", async () => {
|
|
const ip = randomIP();
|
|
const email = `test-${Date.now()}@example.com`;
|
|
|
|
// IP rate limiting is skipped in test/dev, so only email limit applies
|
|
for (let i = 0; i < RATE_LIMITS.LOGIN_EMAIL.maxAttempts; i++) {
|
|
await rateLimitLogin(email, ip);
|
|
}
|
|
|
|
try {
|
|
await rateLimitLogin(email, ip);
|
|
expect.unreachable("Should have thrown");
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(TRPCError);
|
|
}
|
|
});
|
|
|
|
it("should limit by email independently of IP", async () => {
|
|
const email = `test-${Date.now()}@example.com`;
|
|
|
|
for (let i = 0; i < RATE_LIMITS.LOGIN_EMAIL.maxAttempts; i++) {
|
|
await rateLimitLogin(email, randomIP());
|
|
}
|
|
|
|
try {
|
|
await rateLimitLogin(email, randomIP());
|
|
expect.unreachable("Should have thrown");
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(TRPCError);
|
|
}
|
|
});
|
|
|
|
it("should allow different emails in test/dev environments (IP limit skipped)", async () => {
|
|
const ip = randomIP();
|
|
|
|
// In test/dev, IP rate limiting is skipped
|
|
for (let i = 0; i < 10; i++) {
|
|
const email = `test${i}-${Date.now()}@example.com`;
|
|
await rateLimitLogin(email, ip);
|
|
}
|
|
|
|
expect(true).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("rateLimitPasswordReset", () => {
|
|
it("should not enforce IP rate limits in test/dev environments", async () => {
|
|
const ip = randomIP();
|
|
|
|
// IP rate limiting is skipped in test/dev
|
|
for (let i = 0; i < 10; i++) {
|
|
await rateLimitPasswordReset(ip);
|
|
}
|
|
|
|
expect(true).toBe(true);
|
|
});
|
|
|
|
it("should allow password resets in test/dev (no IP limit)", async () => {
|
|
const ip = randomIP();
|
|
const email = `test-${Date.now()}@example.com`;
|
|
|
|
// Use up many password reset attempts (no IP limit in test/dev)
|
|
for (let i = 0; i < 10; i++) {
|
|
await rateLimitPasswordReset(ip);
|
|
}
|
|
|
|
// Should still be able to login (different limit and function)
|
|
await rateLimitLogin(email, ip);
|
|
});
|
|
});
|
|
|
|
describe("rateLimitRegistration", () => {
|
|
it("should not enforce IP rate limits in test/dev environments", async () => {
|
|
const ip = randomIP();
|
|
|
|
// IP rate limiting is skipped in test/dev
|
|
for (let i = 0; i < 10; i++) {
|
|
await rateLimitRegistration(ip);
|
|
}
|
|
|
|
expect(true).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("rateLimitEmailVerification", () => {
|
|
it("should not enforce IP rate limits in test/dev environments", async () => {
|
|
const ip = randomIP();
|
|
|
|
// IP rate limiting is skipped in test/dev
|
|
for (let i = 0; i < 10; i++) {
|
|
await rateLimitEmailVerification(ip);
|
|
}
|
|
|
|
expect(true).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("Rate Limit Attack Scenarios", () => {
|
|
it("should prevent brute force login attacks", async () => {
|
|
const email = "victim@example.com";
|
|
const attackerIP = "1.2.3.4";
|
|
|
|
let blockedAtAttempt = 0;
|
|
for (let i = 0; i < 10; i++) {
|
|
try {
|
|
await rateLimitLogin(email, attackerIP);
|
|
} catch (error) {
|
|
if (error instanceof TRPCError) {
|
|
blockedAtAttempt = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
expect(blockedAtAttempt).toBeLessThan(10);
|
|
expect(blockedAtAttempt).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("should prevent distributed brute force from multiple IPs", async () => {
|
|
const email = "victim@example.com";
|
|
|
|
let blockedAtAttempt = 0;
|
|
for (let i = 0; i < 10; i++) {
|
|
try {
|
|
await rateLimitLogin(email, randomIP());
|
|
} catch (error) {
|
|
if (error instanceof TRPCError) {
|
|
blockedAtAttempt = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
expect(blockedAtAttempt).toBeLessThanOrEqual(
|
|
RATE_LIMITS.LOGIN_EMAIL.maxAttempts
|
|
);
|
|
});
|
|
|
|
it("should prevent account enumeration via registration spam", async () => {
|
|
const attackerIP = randomIP();
|
|
|
|
// In test/dev, IP rate limiting is skipped
|
|
// This test verifies the behavior is as expected (no blocking)
|
|
for (let i = 0; i < 10; i++) {
|
|
await rateLimitRegistration(attackerIP);
|
|
}
|
|
|
|
expect(true).toBe(true);
|
|
});
|
|
|
|
it("should prevent password reset spam attacks", async () => {
|
|
const attackerIP = randomIP();
|
|
|
|
// In test/dev, IP rate limiting is skipped
|
|
// This test verifies the behavior is as expected (no blocking)
|
|
for (let i = 0; i < 10; i++) {
|
|
await rateLimitPasswordReset(attackerIP);
|
|
}
|
|
|
|
expect(true).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("Unknown IP Handling", () => {
|
|
it("should handle 'unknown' IP in development without rate limiting", async () => {
|
|
// In development, IP rate limits should be skipped
|
|
// This test assumes NODE_ENV is 'development' or 'test'
|
|
const unknownIP = "unknown";
|
|
const email = `test-${Date.now()}@example.com`;
|
|
|
|
// (only email rate limit applies)
|
|
for (let i = 0; i < RATE_LIMITS.LOGIN_EMAIL.maxAttempts; i++) {
|
|
const testEmail = `test-${Date.now()}-${i}@example.com`;
|
|
await rateLimitLogin(testEmail, unknownIP);
|
|
}
|
|
|
|
await rateLimitLogin(`final-${Date.now()}@example.com`, unknownIP);
|
|
});
|
|
|
|
it("should still enforce email rate limits with unknown IP", async () => {
|
|
const unknownIP = "unknown";
|
|
const email = `test-${Date.now()}@example.com`;
|
|
|
|
for (let i = 0; i < RATE_LIMITS.LOGIN_EMAIL.maxAttempts; i++) {
|
|
await rateLimitLogin(email, unknownIP);
|
|
}
|
|
|
|
try {
|
|
await rateLimitLogin(email, unknownIP);
|
|
expect.unreachable("Should have thrown");
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(TRPCError);
|
|
}
|
|
});
|
|
|
|
it("should handle unknown IP in password reset", async () => {
|
|
const unknownIP = "unknown";
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
await rateLimitPasswordReset(unknownIP);
|
|
}
|
|
|
|
expect(true).toBe(true);
|
|
});
|
|
|
|
it("should handle unknown IP in registration", async () => {
|
|
const unknownIP = "unknown";
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
await rateLimitRegistration(unknownIP);
|
|
}
|
|
|
|
expect(true).toBe(true);
|
|
});
|
|
|
|
it("should handle unknown IP in email verification", async () => {
|
|
const unknownIP = "unknown";
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
await rateLimitEmailVerification(unknownIP);
|
|
}
|
|
|
|
expect(true).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("Rate Limit Configuration", () => {
|
|
it("should have reasonable limits configured", () => {
|
|
expect(RATE_LIMITS.LOGIN_IP.maxAttempts).toBeGreaterThan(
|
|
RATE_LIMITS.REGISTRATION_IP.maxAttempts
|
|
);
|
|
|
|
expect(RATE_LIMITS.LOGIN_IP.maxAttempts).toBeGreaterThan(0);
|
|
expect(RATE_LIMITS.LOGIN_EMAIL.maxAttempts).toBeGreaterThan(0);
|
|
expect(RATE_LIMITS.PASSWORD_RESET_IP.maxAttempts).toBeGreaterThan(0);
|
|
expect(RATE_LIMITS.REGISTRATION_IP.maxAttempts).toBeGreaterThan(0);
|
|
expect(RATE_LIMITS.EMAIL_VERIFICATION_IP.maxAttempts).toBeGreaterThan(0);
|
|
|
|
expect(RATE_LIMITS.LOGIN_IP.windowMs).toBeGreaterThanOrEqual(60000);
|
|
expect(RATE_LIMITS.LOGIN_EMAIL.windowMs).toBeGreaterThanOrEqual(60000);
|
|
expect(RATE_LIMITS.PASSWORD_RESET_IP.windowMs).toBeGreaterThanOrEqual(
|
|
60000
|
|
);
|
|
expect(RATE_LIMITS.REGISTRATION_IP.windowMs).toBeGreaterThanOrEqual(
|
|
60000
|
|
);
|
|
expect(RATE_LIMITS.EMAIL_VERIFICATION_IP.windowMs).toBeGreaterThanOrEqual(
|
|
60000
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("Performance", () => {
|
|
it("should keep single-key shared-store check latency within an acceptable bound", async () => {
|
|
// p8-010: the rate-limit state now lives in the shared DB store instead of
|
|
// an in-memory Map. The latency that matters for logins is a single
|
|
// checkRateLimit round-trip, not aggregate throughput. Assert it stays
|
|
// within an acceptable bound for a remote shared store.
|
|
const id = uniqueId("perf");
|
|
const maxAttempts = 5;
|
|
const windowMs = 60000;
|
|
|
|
// Warm the bucket so we measure the ON CONFLICT UPDATE path.
|
|
await checkRateLimit(id, maxAttempts, windowMs);
|
|
|
|
const start = performance.now();
|
|
await checkRateLimit(id, maxAttempts, windowMs);
|
|
const singleLatency = performance.now() - start;
|
|
|
|
// Generous bound for a remote libSQL/Turso round-trip; catches gross
|
|
// regressions (e.g. falling back to multi-statement SELECT+UPDATE).
|
|
expect(singleLatency).toBeLessThan(2000);
|
|
}, 15000);
|
|
|
|
it("should not crash with many distinct identifiers", async () => {
|
|
// Each call performs a DB upsert; keep the volume bounded so the test
|
|
// stays well under the remote-DB latency budget.
|
|
const promises = [];
|
|
for (let i = 0; i < 30; i++) {
|
|
promises.push(checkRateLimit(uniqueId("perf-many"), 5, 60000));
|
|
}
|
|
await Promise.all(promises);
|
|
|
|
// This test mainly ensures no crashes occur under concurrent upserts.
|
|
// Memory cleanup is tested by the cleanup interval in security.ts.
|
|
expect(true).toBe(true);
|
|
}, 20000);
|
|
});
|
|
|
|
// ===========================================================================
|
|
// p8-010: distributed rate-limit store. The authoritative counter lives in the
|
|
// shared `RateLimit` DB table (atomic upsert), so limits hold across instances
|
|
// and survive restarts/redeploys. The per-instance Map is now only a short-TTL
|
|
// local cache for fast-failing already-blocked identifiers.
|
|
// ===========================================================================
|
|
describe("Distributed rate-limit store (p8-010)", () => {
|
|
it("state survives a simulated instance restart (local cache cleared, shared store blocks)", async () => {
|
|
const id = uniqueId("dist-restart");
|
|
const maxAttempts = 3;
|
|
const windowMs = 60000;
|
|
|
|
for (let i = 0; i < maxAttempts; i++) {
|
|
await checkRateLimit(id, maxAttempts, windowMs);
|
|
}
|
|
await expect(checkRateLimit(id, maxAttempts, windowMs)).rejects.toThrow(
|
|
TRPCError
|
|
);
|
|
|
|
// Simulate an instance restart: wipe ONLY the in-memory cache. A naive
|
|
// per-instance Map would lose the block here; the shared store must keep
|
|
// blocking from the DB.
|
|
clearRateLimitLocalCache();
|
|
await expect(checkRateLimit(id, maxAttempts, windowMs)).rejects.toThrow(
|
|
TRPCError
|
|
);
|
|
});
|
|
|
|
it("two simulated instances aggregate the count for the same key", async () => {
|
|
const id = uniqueId("dist-multi");
|
|
const maxAttempts = 5;
|
|
const windowMs = 60000;
|
|
|
|
clearRateLimitLocalCache();
|
|
for (let i = 0; i < 3; i++) {
|
|
await checkRateLimit(id, maxAttempts, windowMs);
|
|
}
|
|
|
|
// Instance B (fresh local cache) makes 2 more -> combined count = 5.
|
|
clearRateLimitLocalCache();
|
|
await checkRateLimit(id, maxAttempts, windowMs);
|
|
const remaining = await checkRateLimit(id, maxAttempts, windowMs);
|
|
expect(remaining).toBe(0);
|
|
|
|
// A 6th attempt from a fresh instance must be blocked — the shared store
|
|
// aggregated the count across the two "instances".
|
|
clearRateLimitLocalCache();
|
|
await expect(checkRateLimit(id, maxAttempts, windowMs)).rejects.toThrow(
|
|
TRPCError
|
|
);
|
|
});
|
|
|
|
it("cannot bypass the limit by alternating between instances", async () => {
|
|
const id = uniqueId("dist-bypass");
|
|
const maxAttempts = 4;
|
|
const windowMs = 60000;
|
|
|
|
// Each request simulates landing on a different instance (fresh local
|
|
// cache). The shared DB counter must still aggregate every hit.
|
|
for (let i = 0; i < maxAttempts; i++) {
|
|
clearRateLimitLocalCache();
|
|
await checkRateLimit(id, maxAttempts, windowMs);
|
|
}
|
|
|
|
clearRateLimitLocalCache();
|
|
await expect(checkRateLimit(id, maxAttempts, windowMs)).rejects.toThrow(
|
|
TRPCError
|
|
);
|
|
});
|
|
});
|
|
});
|