diff --git a/src/server/middleware/rateLimit.ts b/src/server/middleware/rateLimit.ts new file mode 100644 index 0000000..85e43fe --- /dev/null +++ b/src/server/middleware/rateLimit.ts @@ -0,0 +1,48 @@ +import type { Context, Next } from "hono"; + +interface RateLimitEntry { + count: number; + resetAt: number; +} + +const store = new Map(); + +const MAX_ATTEMPTS = 5; +const WINDOW_MS = 15 * 60 * 1000; // 15 minutes + +function getClientIp(c: Context): string { + return c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || "unknown"; +} + +function cleanup() { + const now = Date.now(); + for (const [key, entry] of store) { + if (now >= entry.resetAt) { + store.delete(key); + } + } +} + +export async function rateLimit(c: Context, next: Next) { + cleanup(); + + const ip = getClientIp(c); + const key = `${ip}:${c.req.path}`; + const now = Date.now(); + + const entry = store.get(key); + + if (!entry || now >= entry.resetAt) { + store.set(key, { count: 1, resetAt: now + WINDOW_MS }); + return next(); + } + + if (entry.count >= MAX_ATTEMPTS) { + const retryAfter = Math.ceil((entry.resetAt - now) / 1000); + c.header("Retry-After", String(retryAfter)); + return c.json({ error: "Too many attempts. Try again later." }, 429); + } + + entry.count++; + return next(); +} diff --git a/src/server/routes/auth.ts b/src/server/routes/auth.ts index f929b33..827e544 100644 --- a/src/server/routes/auth.ts +++ b/src/server/routes/auth.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import { users } from "../../db/schema.ts"; import { parseId } from "../lib/params.ts"; import { requireAuth } from "../middleware/auth.ts"; +import { rateLimit } from "../middleware/rateLimit.ts"; import { changePassword, createApiKey, @@ -60,7 +61,7 @@ app.get("/me", (c) => { return c.json({ user: null, setupRequired }); }); -app.post("/setup", zValidator("json", setupSchema), async (c) => { +app.post("/setup", rateLimit, zValidator("json", setupSchema), async (c) => { const db = c.get("db"); if (getUserCount(db) > 0) { @@ -81,7 +82,7 @@ app.post("/setup", zValidator("json", setupSchema), async (c) => { return c.json({ username: user.username }, 201); }); -app.post("/login", zValidator("json", loginSchema), async (c) => { +app.post("/login", rateLimit, zValidator("json", loginSchema), async (c) => { const db = c.get("db"); const { username, password } = c.req.valid("json");