🪻 distributed transcription service thistle.dunkirk.sh
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add rate limiting

+284
+13
src/db/schema.ts
··· 76 76 ALTER TABLE transcriptions DROP COLUMN transcript; 77 77 `, 78 78 }, 79 + { 80 + version: 5, 81 + name: "Add rate limiting table", 82 + sql: ` 83 + CREATE TABLE IF NOT EXISTS rate_limit_attempts ( 84 + id INTEGER PRIMARY KEY AUTOINCREMENT, 85 + key TEXT NOT NULL, 86 + timestamp INTEGER NOT NULL 87 + ); 88 + 89 + CREATE INDEX IF NOT EXISTS idx_rate_limit_key_timestamp ON rate_limit_attempts(key, timestamp); 90 + `, 91 + }, 79 92 ]; 80 93 81 94 function getCurrentVersion(): number {
+36
src/index.ts
··· 17 17 } from "./lib/auth"; 18 18 import { handleError, ValidationErrors } from "./lib/errors"; 19 19 import { requireAuth } from "./lib/middleware"; 20 + import { enforceRateLimit } from "./lib/rate-limit"; 20 21 import { 21 22 MAX_FILE_SIZE, 22 23 TranscriptionEventEmitter, ··· 86 87 "/api/auth/register": { 87 88 POST: async (req) => { 88 89 try { 90 + // Rate limiting 91 + const rateLimitError = enforceRateLimit(req, "register", { 92 + ip: { max: 5, windowSeconds: 60 * 60 }, 93 + }); 94 + if (rateLimitError) return rateLimitError; 95 + 89 96 const body = await req.json(); 90 97 const { email, password, name } = body; 91 98 if (!email || !password) { ··· 142 149 { status: 400 }, 143 150 ); 144 151 } 152 + 153 + // Rate limiting: Per IP and per account 154 + const rateLimitError = enforceRateLimit(req, "login", { 155 + ip: { max: 10, windowSeconds: 15 * 60 }, 156 + account: { max: 5, windowSeconds: 15 * 60, email }, 157 + }); 158 + if (rateLimitError) return rateLimitError; 159 + 145 160 // Password is client-side hashed (PBKDF2), should be 64 char hex 146 161 if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) { 147 162 return Response.json( ··· 267 282 if (!user) { 268 283 return Response.json({ error: "Invalid session" }, { status: 401 }); 269 284 } 285 + 286 + // Rate limiting 287 + const rateLimitError = enforceRateLimit(req, "delete-user", { 288 + ip: { max: 3, windowSeconds: 60 * 60 }, 289 + }); 290 + if (rateLimitError) return rateLimitError; 291 + 270 292 deleteUser(user.id); 271 293 return Response.json( 272 294 { success: true }, ··· 289 311 if (!user) { 290 312 return Response.json({ error: "Invalid session" }, { status: 401 }); 291 313 } 314 + 315 + // Rate limiting 316 + const rateLimitError = enforceRateLimit(req, "update-email", { 317 + ip: { max: 5, windowSeconds: 60 * 60 }, 318 + }); 319 + if (rateLimitError) return rateLimitError; 320 + 292 321 const body = await req.json(); 293 322 const { email } = body; 294 323 if (!email) { ··· 322 351 if (!user) { 323 352 return Response.json({ error: "Invalid session" }, { status: 401 }); 324 353 } 354 + 355 + // Rate limiting 356 + const rateLimitError = enforceRateLimit(req, "update-password", { 357 + ip: { max: 5, windowSeconds: 60 * 60 }, 358 + }); 359 + if (rateLimitError) return rateLimitError; 360 + 325 361 const body = await req.json(); 326 362 const { password } = body; 327 363 if (!password) {
+110
src/lib/rate-limit.test.ts
··· 1 + import { expect, test } from "bun:test"; 2 + import { checkRateLimit, cleanupOldAttempts } from "./rate-limit"; 3 + import db from "../db/schema"; 4 + 5 + // Clean up before tests 6 + db.run("DELETE FROM rate_limit_attempts"); 7 + 8 + test("allows requests under the limit", () => { 9 + const key = "test:allow"; 10 + 11 + const result1 = checkRateLimit(key, 5, 60); 12 + expect(result1.allowed).toBe(true); 13 + 14 + const result2 = checkRateLimit(key, 5, 60); 15 + expect(result2.allowed).toBe(true); 16 + 17 + const result3 = checkRateLimit(key, 5, 60); 18 + expect(result3.allowed).toBe(true); 19 + }); 20 + 21 + test("blocks requests over the limit", () => { 22 + const key = "test:block"; 23 + 24 + // Make 5 requests (limit) 25 + for (let i = 0; i < 5; i++) { 26 + const result = checkRateLimit(key, 5, 60); 27 + expect(result.allowed).toBe(true); 28 + } 29 + 30 + // 6th request should be blocked 31 + const blocked = checkRateLimit(key, 5, 60); 32 + expect(blocked.allowed).toBe(false); 33 + expect(blocked.retryAfter).toBeGreaterThan(0); 34 + }); 35 + 36 + test("rolling window allows requests after time passes", async () => { 37 + const key = "test:rolling"; 38 + 39 + // Make 3 requests 40 + for (let i = 0; i < 3; i++) { 41 + checkRateLimit(key, 3, 2); // 3 per 2 seconds 42 + } 43 + 44 + // 4th should be blocked 45 + let result = checkRateLimit(key, 3, 2); 46 + expect(result.allowed).toBe(false); 47 + 48 + // Wait for window to pass 49 + await new Promise((resolve) => setTimeout(resolve, 2100)); 50 + 51 + // Should now be allowed (old attempts outside window) 52 + result = checkRateLimit(key, 3, 2); 53 + expect(result.allowed).toBe(true); 54 + }); 55 + 56 + test("different keys are tracked separately", () => { 57 + const key1 = "test:separate1"; 58 + const key2 = "test:separate2"; 59 + 60 + // Exhaust limit for key1 61 + for (let i = 0; i < 5; i++) { 62 + checkRateLimit(key1, 5, 60); 63 + } 64 + 65 + const blocked = checkRateLimit(key1, 5, 60); 66 + expect(blocked.allowed).toBe(false); 67 + 68 + // key2 should still be allowed 69 + const allowed = checkRateLimit(key2, 5, 60); 70 + expect(allowed.allowed).toBe(true); 71 + }); 72 + 73 + test("cleanup removes old attempts", () => { 74 + const key = "test:cleanup"; 75 + 76 + // Create some attempts 77 + checkRateLimit(key, 10, 60); 78 + checkRateLimit(key, 10, 60); 79 + 80 + // Manually insert an old attempt (25 hours ago) 81 + const oldTimestamp = Math.floor(Date.now() / 1000) - 25 * 60 * 60; 82 + db.run("INSERT INTO rate_limit_attempts (key, timestamp) VALUES (?, ?)", [ 83 + "test:old", 84 + oldTimestamp, 85 + ]); 86 + 87 + // Run cleanup (default: removes attempts older than 24 hours) 88 + cleanupOldAttempts(); 89 + 90 + // Old attempt should be gone 91 + const count = db 92 + .query<{ count: number }, []>( 93 + "SELECT COUNT(*) as count FROM rate_limit_attempts WHERE key = 'test:old'", 94 + ) 95 + .get(); 96 + 97 + expect(count?.count).toBe(0); 98 + 99 + // Recent attempts should still exist 100 + const recentCount = db 101 + .query<{ count: number }, [string]>( 102 + "SELECT COUNT(*) as count FROM rate_limit_attempts WHERE key = ?", 103 + ) 104 + .get(key); 105 + 106 + expect(recentCount?.count).toBe(2); 107 + }); 108 + 109 + // Cleanup after tests 110 + db.run("DELETE FROM rate_limit_attempts");
+125
src/lib/rate-limit.ts
··· 1 + import db from "../db/schema"; 2 + 3 + export interface RateLimitResult { 4 + allowed: boolean; 5 + retryAfter?: number; 6 + } 7 + 8 + export interface RateLimitConfig { 9 + ip?: { max: number; windowSeconds: number }; 10 + account?: { max: number; windowSeconds: number; email: string }; 11 + } 12 + 13 + export function checkRateLimit( 14 + key: string, 15 + maxAttempts: number, 16 + windowSeconds: number, 17 + ): RateLimitResult { 18 + const now = Math.floor(Date.now() / 1000); 19 + const windowStart = now - windowSeconds; 20 + 21 + // Count attempts in rolling window 22 + const count = db 23 + .query<{ count: number }, [string, number]>( 24 + "SELECT COUNT(*) as count FROM rate_limit_attempts WHERE key = ? AND timestamp > ?", 25 + ) 26 + .get(key, windowStart); 27 + 28 + const attemptCount = count?.count ?? 0; 29 + 30 + if (attemptCount >= maxAttempts) { 31 + // Find oldest attempt in window to calculate retry time 32 + const oldest = db 33 + .query<{ timestamp: number }, [string, number]>( 34 + "SELECT timestamp FROM rate_limit_attempts WHERE key = ? AND timestamp > ? ORDER BY timestamp ASC LIMIT 1", 35 + ) 36 + .get(key, windowStart); 37 + 38 + const retryAfter = oldest 39 + ? oldest.timestamp + windowSeconds - now 40 + : windowSeconds; 41 + 42 + return { 43 + allowed: false, 44 + retryAfter: Math.max(retryAfter, 1), 45 + }; 46 + } 47 + 48 + // Record this attempt 49 + db.run("INSERT INTO rate_limit_attempts (key, timestamp) VALUES (?, ?)", [ 50 + key, 51 + now, 52 + ]); 53 + 54 + return { allowed: true }; 55 + } 56 + 57 + export function enforceRateLimit( 58 + req: Request, 59 + endpoint: string, 60 + config: RateLimitConfig, 61 + ): Response | null { 62 + const ipAddress = 63 + req.headers.get("x-forwarded-for") ?? 64 + req.headers.get("x-real-ip") ?? 65 + "unknown"; 66 + 67 + // Check IP-based rate limit 68 + if (config.ip) { 69 + const ipLimit = checkRateLimit( 70 + `${endpoint}:ip:${ipAddress}`, 71 + config.ip.max, 72 + config.ip.windowSeconds, 73 + ); 74 + 75 + if (!ipLimit.allowed) { 76 + return Response.json( 77 + { 78 + error: `Too many requests. Try again in ${ipLimit.retryAfter} seconds.`, 79 + }, 80 + { 81 + status: 429, 82 + headers: { "Retry-After": String(ipLimit.retryAfter) }, 83 + }, 84 + ); 85 + } 86 + } 87 + 88 + // Check account-based rate limit 89 + if (config.account) { 90 + const accountLimit = checkRateLimit( 91 + `${endpoint}:account:${config.account.email.toLowerCase()}`, 92 + config.account.max, 93 + config.account.windowSeconds, 94 + ); 95 + 96 + if (!accountLimit.allowed) { 97 + return Response.json( 98 + { 99 + error: `Too many attempts for this account. Try again in ${accountLimit.retryAfter} seconds.`, 100 + }, 101 + { 102 + status: 429, 103 + headers: { "Retry-After": String(accountLimit.retryAfter) }, 104 + }, 105 + ); 106 + } 107 + } 108 + 109 + return null; // Allowed 110 + } 111 + 112 + export function cleanupOldAttempts(olderThanSeconds = 86400) { 113 + // Clean up attempts older than specified time (default: 24 hours) 114 + const cutoff = Math.floor(Date.now() / 1000) - olderThanSeconds; 115 + db.run("DELETE FROM rate_limit_attempts WHERE timestamp < ?", [cutoff]); 116 + } 117 + 118 + // Run cleanup on module load and periodically 119 + cleanupOldAttempts(); 120 + setInterval( 121 + () => { 122 + cleanupOldAttempts(); 123 + }, 124 + 60 * 60 * 1000, 125 + ); // Every hour