🪻 distributed transcription service thistle.dunkirk.sh

feat: add rate limiting

dunkirk.sh 9ab4aadd d0a7d344

verified
+13
src/db/schema.ts
···
ALTER TABLE transcriptions DROP COLUMN transcript;
`,
},
+
{
+
version: 5,
+
name: "Add rate limiting table",
+
sql: `
+
CREATE TABLE IF NOT EXISTS rate_limit_attempts (
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
+
key TEXT NOT NULL,
+
timestamp INTEGER NOT NULL
+
);
+
+
CREATE INDEX IF NOT EXISTS idx_rate_limit_key_timestamp ON rate_limit_attempts(key, timestamp);
+
`,
+
},
];
function getCurrentVersion(): number {
+36
src/index.ts
···
} from "./lib/auth";
import { handleError, ValidationErrors } from "./lib/errors";
import { requireAuth } from "./lib/middleware";
+
import { enforceRateLimit } from "./lib/rate-limit";
import {
MAX_FILE_SIZE,
TranscriptionEventEmitter,
···
"/api/auth/register": {
POST: async (req) => {
try {
+
// Rate limiting
+
const rateLimitError = enforceRateLimit(req, "register", {
+
ip: { max: 5, windowSeconds: 60 * 60 },
+
});
+
if (rateLimitError) return rateLimitError;
+
const body = await req.json();
const { email, password, name } = body;
if (!email || !password) {
···
{ status: 400 },
);
}
+
+
// Rate limiting: Per IP and per account
+
const rateLimitError = enforceRateLimit(req, "login", {
+
ip: { max: 10, windowSeconds: 15 * 60 },
+
account: { max: 5, windowSeconds: 15 * 60, email },
+
});
+
if (rateLimitError) return rateLimitError;
+
// Password is client-side hashed (PBKDF2), should be 64 char hex
if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) {
return Response.json(
···
if (!user) {
return Response.json({ error: "Invalid session" }, { status: 401 });
}
+
+
// Rate limiting
+
const rateLimitError = enforceRateLimit(req, "delete-user", {
+
ip: { max: 3, windowSeconds: 60 * 60 },
+
});
+
if (rateLimitError) return rateLimitError;
+
deleteUser(user.id);
return Response.json(
{ success: true },
···
if (!user) {
return Response.json({ error: "Invalid session" }, { status: 401 });
}
+
+
// Rate limiting
+
const rateLimitError = enforceRateLimit(req, "update-email", {
+
ip: { max: 5, windowSeconds: 60 * 60 },
+
});
+
if (rateLimitError) return rateLimitError;
+
const body = await req.json();
const { email } = body;
if (!email) {
···
if (!user) {
return Response.json({ error: "Invalid session" }, { status: 401 });
}
+
+
// Rate limiting
+
const rateLimitError = enforceRateLimit(req, "update-password", {
+
ip: { max: 5, windowSeconds: 60 * 60 },
+
});
+
if (rateLimitError) return rateLimitError;
+
const body = await req.json();
const { password } = body;
if (!password) {
+110
src/lib/rate-limit.test.ts
···
+
import { expect, test } from "bun:test";
+
import { checkRateLimit, cleanupOldAttempts } from "./rate-limit";
+
import db from "../db/schema";
+
+
// Clean up before tests
+
db.run("DELETE FROM rate_limit_attempts");
+
+
test("allows requests under the limit", () => {
+
const key = "test:allow";
+
+
const result1 = checkRateLimit(key, 5, 60);
+
expect(result1.allowed).toBe(true);
+
+
const result2 = checkRateLimit(key, 5, 60);
+
expect(result2.allowed).toBe(true);
+
+
const result3 = checkRateLimit(key, 5, 60);
+
expect(result3.allowed).toBe(true);
+
});
+
+
test("blocks requests over the limit", () => {
+
const key = "test:block";
+
+
// Make 5 requests (limit)
+
for (let i = 0; i < 5; i++) {
+
const result = checkRateLimit(key, 5, 60);
+
expect(result.allowed).toBe(true);
+
}
+
+
// 6th request should be blocked
+
const blocked = checkRateLimit(key, 5, 60);
+
expect(blocked.allowed).toBe(false);
+
expect(blocked.retryAfter).toBeGreaterThan(0);
+
});
+
+
test("rolling window allows requests after time passes", async () => {
+
const key = "test:rolling";
+
+
// Make 3 requests
+
for (let i = 0; i < 3; i++) {
+
checkRateLimit(key, 3, 2); // 3 per 2 seconds
+
}
+
+
// 4th should be blocked
+
let result = checkRateLimit(key, 3, 2);
+
expect(result.allowed).toBe(false);
+
+
// Wait for window to pass
+
await new Promise((resolve) => setTimeout(resolve, 2100));
+
+
// Should now be allowed (old attempts outside window)
+
result = checkRateLimit(key, 3, 2);
+
expect(result.allowed).toBe(true);
+
});
+
+
test("different keys are tracked separately", () => {
+
const key1 = "test:separate1";
+
const key2 = "test:separate2";
+
+
// Exhaust limit for key1
+
for (let i = 0; i < 5; i++) {
+
checkRateLimit(key1, 5, 60);
+
}
+
+
const blocked = checkRateLimit(key1, 5, 60);
+
expect(blocked.allowed).toBe(false);
+
+
// key2 should still be allowed
+
const allowed = checkRateLimit(key2, 5, 60);
+
expect(allowed.allowed).toBe(true);
+
});
+
+
test("cleanup removes old attempts", () => {
+
const key = "test:cleanup";
+
+
// Create some attempts
+
checkRateLimit(key, 10, 60);
+
checkRateLimit(key, 10, 60);
+
+
// Manually insert an old attempt (25 hours ago)
+
const oldTimestamp = Math.floor(Date.now() / 1000) - 25 * 60 * 60;
+
db.run("INSERT INTO rate_limit_attempts (key, timestamp) VALUES (?, ?)", [
+
"test:old",
+
oldTimestamp,
+
]);
+
+
// Run cleanup (default: removes attempts older than 24 hours)
+
cleanupOldAttempts();
+
+
// Old attempt should be gone
+
const count = db
+
.query<{ count: number }, []>(
+
"SELECT COUNT(*) as count FROM rate_limit_attempts WHERE key = 'test:old'",
+
)
+
.get();
+
+
expect(count?.count).toBe(0);
+
+
// Recent attempts should still exist
+
const recentCount = db
+
.query<{ count: number }, [string]>(
+
"SELECT COUNT(*) as count FROM rate_limit_attempts WHERE key = ?",
+
)
+
.get(key);
+
+
expect(recentCount?.count).toBe(2);
+
});
+
+
// Cleanup after tests
+
db.run("DELETE FROM rate_limit_attempts");
+125
src/lib/rate-limit.ts
···
+
import db from "../db/schema";
+
+
export interface RateLimitResult {
+
allowed: boolean;
+
retryAfter?: number;
+
}
+
+
export interface RateLimitConfig {
+
ip?: { max: number; windowSeconds: number };
+
account?: { max: number; windowSeconds: number; email: string };
+
}
+
+
export function checkRateLimit(
+
key: string,
+
maxAttempts: number,
+
windowSeconds: number,
+
): RateLimitResult {
+
const now = Math.floor(Date.now() / 1000);
+
const windowStart = now - windowSeconds;
+
+
// Count attempts in rolling window
+
const count = db
+
.query<{ count: number }, [string, number]>(
+
"SELECT COUNT(*) as count FROM rate_limit_attempts WHERE key = ? AND timestamp > ?",
+
)
+
.get(key, windowStart);
+
+
const attemptCount = count?.count ?? 0;
+
+
if (attemptCount >= maxAttempts) {
+
// Find oldest attempt in window to calculate retry time
+
const oldest = db
+
.query<{ timestamp: number }, [string, number]>(
+
"SELECT timestamp FROM rate_limit_attempts WHERE key = ? AND timestamp > ? ORDER BY timestamp ASC LIMIT 1",
+
)
+
.get(key, windowStart);
+
+
const retryAfter = oldest
+
? oldest.timestamp + windowSeconds - now
+
: windowSeconds;
+
+
return {
+
allowed: false,
+
retryAfter: Math.max(retryAfter, 1),
+
};
+
}
+
+
// Record this attempt
+
db.run("INSERT INTO rate_limit_attempts (key, timestamp) VALUES (?, ?)", [
+
key,
+
now,
+
]);
+
+
return { allowed: true };
+
}
+
+
export function enforceRateLimit(
+
req: Request,
+
endpoint: string,
+
config: RateLimitConfig,
+
): Response | null {
+
const ipAddress =
+
req.headers.get("x-forwarded-for") ??
+
req.headers.get("x-real-ip") ??
+
"unknown";
+
+
// Check IP-based rate limit
+
if (config.ip) {
+
const ipLimit = checkRateLimit(
+
`${endpoint}:ip:${ipAddress}`,
+
config.ip.max,
+
config.ip.windowSeconds,
+
);
+
+
if (!ipLimit.allowed) {
+
return Response.json(
+
{
+
error: `Too many requests. Try again in ${ipLimit.retryAfter} seconds.`,
+
},
+
{
+
status: 429,
+
headers: { "Retry-After": String(ipLimit.retryAfter) },
+
},
+
);
+
}
+
}
+
+
// Check account-based rate limit
+
if (config.account) {
+
const accountLimit = checkRateLimit(
+
`${endpoint}:account:${config.account.email.toLowerCase()}`,
+
config.account.max,
+
config.account.windowSeconds,
+
);
+
+
if (!accountLimit.allowed) {
+
return Response.json(
+
{
+
error: `Too many attempts for this account. Try again in ${accountLimit.retryAfter} seconds.`,
+
},
+
{
+
status: 429,
+
headers: { "Retry-After": String(accountLimit.retryAfter) },
+
},
+
);
+
}
+
}
+
+
return null; // Allowed
+
}
+
+
export function cleanupOldAttempts(olderThanSeconds = 86400) {
+
// Clean up attempts older than specified time (default: 24 hours)
+
const cutoff = Math.floor(Date.now() / 1000) - olderThanSeconds;
+
db.run("DELETE FROM rate_limit_attempts WHERE timestamp < ?", [cutoff]);
+
}
+
+
// Run cleanup on module load and periodically
+
cleanupOldAttempts();
+
setInterval(
+
() => {
+
cleanupOldAttempts();
+
},
+
60 * 60 * 1000,
+
); // Every hour