diff --git a/src/data/sessions.test.ts b/src/data/sessions.test.ts index f0a491a..1505ec3 100644 --- a/src/data/sessions.test.ts +++ b/src/data/sessions.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { createHash } from "node:crypto"; import { createDatabase, type BotDatabase } from "./database.js"; import { createUserStore, type UserStore } from "./users.js"; -import { createSessionStore, type SessionStore, SESSION_TTL_MS, SESSION_TOUCH_INTERVAL_MS } from "./sessions.js"; +import { createSessionStore, type SessionStore, SESSION_TTL_MS, SESSION_TOUCH_INTERVAL_MS, MAX_SESSIONS_PER_USER } from "./sessions.js"; function sha256(token: string) { return createHash("sha256").update(token).digest("hex"); @@ -101,4 +101,19 @@ describe("SessionStore", () => { const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n; expect(remaining).toBe(1); }); + + it("createSession caps concurrent sessions per user at MAX_SESSIONS_PER_USER, evicting oldest", async () => { + // Create MAX + 2 sessions for the same user. + const tokens: string[] = []; + for (let i = 0; i < MAX_SESSIONS_PER_USER + 2; i++) { + tokens.push(sessions.createSession(userId).token); + await new Promise((r) => setTimeout(r, 2)); // stagger createdAt + } + const count = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n; + expect(count).toBe(MAX_SESSIONS_PER_USER); + // The first two should have been evicted, the last MAX remain + expect(sessions.validateAndTouch(tokens[0])).toBeNull(); + expect(sessions.validateAndTouch(tokens[1])).toBeNull(); + expect(sessions.validateAndTouch(tokens[tokens.length - 1])).not.toBeNull(); + }); }); diff --git a/src/data/sessions.ts b/src/data/sessions.ts index 106b3ae..b78a9a8 100644 --- a/src/data/sessions.ts +++ b/src/data/sessions.ts @@ -3,6 +3,7 @@ import type Database from "better-sqlite3"; export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days export const SESSION_TOUCH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour +export const MAX_SESSIONS_PER_USER = 10; export interface SessionValidation { userId: string; @@ -40,9 +41,18 @@ export function createSessionStore(db: Database.Database): SessionStore { "DELETE FROM sessions WHERE userId = ? AND id != ?" ); const cleanupStmt = db.prepare("DELETE FROM sessions WHERE expiresAt < ?"); + const countForUserStmt = db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE userId = ?"); + const deleteOldestForUserStmt = db.prepare( + "DELETE FROM sessions WHERE id IN (SELECT id FROM sessions WHERE userId = ? ORDER BY createdAt ASC LIMIT ?)" + ); return { createSession(userId) { + // Cap concurrent sessions per user — oldest gets evicted on overflow. + const existing = (countForUserStmt.get(userId) as { n: number }).n; + if (existing >= MAX_SESSIONS_PER_USER) { + deleteOldestForUserStmt.run(userId, existing - MAX_SESSIONS_PER_USER + 1); + } const token = randomBytes(32).toString("base64url"); const id = hashToken(token); const now = Date.now(); diff --git a/src/web/middleware/rateLimit.test.ts b/src/web/middleware/rateLimit.test.ts new file mode 100644 index 0000000..73bbd1f --- /dev/null +++ b/src/web/middleware/rateLimit.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import express from "express"; +import request from "supertest"; +import { createRateLimit } from "./rateLimit.js"; + +describe("createRateLimit", () => { + let app: express.Express; + + beforeEach(() => { + app = express(); + // capacity=3, refill=1/sec → first 3 succeed, then 429 until refill. + app.use(createRateLimit({ capacity: 3, refillPerSec: 1 })); + app.get("/", (_req, res) => res.json({ ok: true })); + }); + + it("allows up to capacity bursts then rejects with 429", async () => { + expect((await request(app).get("/")).status).toBe(200); + expect((await request(app).get("/")).status).toBe(200); + expect((await request(app).get("/")).status).toBe(200); + const denied = await request(app).get("/"); + expect(denied.status).toBe(429); + expect(denied.body).toEqual({ error: "rate limit exceeded" }); + expect(denied.headers["retry-after"]).toBeDefined(); + }); + + it("uses per-key buckets when keyFn is provided", async () => { + const customApp = express(); + customApp.use( + createRateLimit({ + capacity: 1, + refillPerSec: 0.001, + keyFn: (req) => req.get("x-user") ?? "anon", + }) + ); + customApp.get("/", (_req, res) => res.json({ ok: true })); + expect((await request(customApp).get("/").set("X-User", "alice")).status).toBe(200); + expect((await request(customApp).get("/").set("X-User", "alice")).status).toBe(429); + // Different user, separate bucket → still has a token. + expect((await request(customApp).get("/").set("X-User", "bob")).status).toBe(200); + }); +}); diff --git a/src/web/middleware/rateLimit.ts b/src/web/middleware/rateLimit.ts new file mode 100644 index 0000000..d93bc9e --- /dev/null +++ b/src/web/middleware/rateLimit.ts @@ -0,0 +1,63 @@ +import type { Request, Response, NextFunction, RequestHandler } from "express"; + +interface Bucket { + tokens: number; + lastRefillMs: number; +} + +interface RateLimitOptions { + /** Bucket capacity (max burst). */ + capacity: number; + /** Tokens refilled per second. */ + refillPerSec: number; + /** Optional key function; defaults to req.ip. */ + keyFn?: (req: Request) => string; +} + +/** + * In-memory token-bucket rate limiter. + * + * Each unique key (default: req.ip) gets its own bucket. Refills continuously + * at `refillPerSec` up to `capacity`. Each request consumes 1 token; if no + * token is available, returns 429 with Retry-After. + * + * Buckets evict themselves after 10 minutes of inactivity to bound memory. + */ +export function createRateLimit(options: RateLimitOptions): RequestHandler { + const buckets = new Map(); + const EVICT_AFTER_MS = 10 * 60 * 1000; + // Periodic eviction to bound memory under attack. + const evict = setInterval(() => { + const cutoff = Date.now() - EVICT_AFTER_MS; + for (const [k, b] of buckets) { + if (b.lastRefillMs < cutoff) buckets.delete(k); + } + }, 60_000); + // Unref the timer so it doesn't keep the process alive in tests. + if (typeof (evict as { unref?: () => void }).unref === "function") { + (evict as { unref: () => void }).unref(); + } + + const keyFn = options.keyFn ?? ((req) => req.ip ?? "unknown"); + + return function rateLimit(req: Request, res: Response, next: NextFunction): void { + const key = keyFn(req); + const now = Date.now(); + let b = buckets.get(key); + if (!b) { + b = { tokens: options.capacity, lastRefillMs: now }; + buckets.set(key, b); + } + const elapsedSec = (now - b.lastRefillMs) / 1000; + b.tokens = Math.min(options.capacity, b.tokens + elapsedSec * options.refillPerSec); + b.lastRefillMs = now; + if (b.tokens < 1) { + const waitSec = Math.ceil((1 - b.tokens) / options.refillPerSec); + res.setHeader("Retry-After", String(waitSec)); + res.status(429).json({ error: "rate limit exceeded" }); + return; + } + b.tokens -= 1; + next(); + }; +} diff --git a/src/web/server.ts b/src/web/server.ts index f1b8785..027f26c 100755 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -24,6 +24,7 @@ import { createSessionStore } from "../data/sessions.js"; import { createRequireAuth } from "./middleware/requireAuth.js"; import { requireAdmin } from "./middleware/requireAdmin.js"; import { csrfOriginCheck } from "./middleware/csrf.js"; +import { createRateLimit } from "./middleware/rateLimit.js"; import { validateSessionFromHeaders } from "./auth/validateSession.js"; const SESSION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour @@ -74,6 +75,14 @@ export function createWebServer(options: WebServerOptions): WebServer { res.json({ publicUrl: raw ? raw.replace(/\/+$/, "") : null }); }); + // Anti-DoS: throttle expensive (bcrypt) auth endpoints. + // 5 req per minute per IP for /login (capacity 5, refill 5/60 = ~0.083/sec). + // 3 req per minute per IP for /setup (more limited; first-run is rare). + const loginLimit = createRateLimit({ capacity: 5, refillPerSec: 5 / 60 }); + const setupLimit = createRateLimit({ capacity: 3, refillPerSec: 3 / 60 }); + app.use("/api/session/login", loginLimit); + app.use("/api/session/setup", setupLimit); + app.use("/api/session", createSessionRouter(users, sessions, audit, logger)); // ─── Gates for everything else under /api ─────────────────────────────── diff --git a/web/src/composables/useSession.ts b/web/src/composables/useSession.ts index b64e680..e8c17ff 100644 --- a/web/src/composables/useSession.ts +++ b/web/src/composables/useSession.ts @@ -10,6 +10,26 @@ const currentUser = ref(null); const needsSetup = ref(null); // null = unknown / not fetched yet const ready = ref(false); +let pollTimer: ReturnType | null = null; +const POLL_INTERVAL_MS = 60_000; + +function ensurePollStarted() { + if (pollTimer !== null) return; + pollTimer = setInterval(() => { + if (currentUser.value !== null) { + // Best-effort refresh; ignore errors (network blips etc.) + refreshMe().catch(() => {}); + } + }, POLL_INTERVAL_MS); +} + +function stopPoll() { + if (pollTimer !== null) { + clearInterval(pollTimer); + pollTimer = null; + } +} + async function refreshNeedsSetup(): Promise { const res = await fetch("/api/session/needs-setup", { credentials: "same-origin" }); if (res.ok) { @@ -35,6 +55,7 @@ async function refresh(): Promise { await refreshMe(); } ready.value = true; + ensurePollStarted(); } async function login(username: string, password: string): Promise { @@ -67,6 +88,7 @@ async function setup(username: string, password: string): Promise { } async function logout(): Promise { + stopPoll(); await fetch("/api/session/logout", { method: "POST", credentials: "same-origin" }); currentUser.value = null; }