diff --git a/bun.lock b/bun.lock index ff88c63..d0a30e5 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "betterbase", diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf index c91d012..ce8a9ea 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -53,7 +53,7 @@ http { proxy_pass http://betterbase_server; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 60s; } @@ -62,13 +62,14 @@ http { proxy_pass http://betterbase_server; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; } location /health { proxy_pass http://betterbase_server; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; } @@ -78,7 +79,7 @@ http { proxy_pass http://minio; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; client_max_body_size 100m; } @@ -88,7 +89,7 @@ http { proxy_pass http://betterbase_dashboard; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; # SPA fallback proxy_intercept_errors on; @@ -98,7 +99,7 @@ http { location @dashboard_fallback { proxy_pass http://betterbase_dashboard; proxy_set_header Host $host; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; } @@ -110,7 +111,7 @@ http { proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 3600s; } diff --git a/packages/server/migrations/017_revoked_admin_tokens.sql b/packages/server/migrations/017_revoked_admin_tokens.sql new file mode 100644 index 0000000..682c2ef --- /dev/null +++ b/packages/server/migrations/017_revoked_admin_tokens.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS betterbase_meta.revoked_admin_tokens ( + jti TEXT PRIMARY KEY, + admin_user_id TEXT REFERENCES betterbase_meta.admin_users(id) ON DELETE SET NULL, + revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_revoked_admin_tokens_expires_at + ON betterbase_meta.revoked_admin_tokens (expires_at); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 6fc5829..9277be1 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -36,7 +36,7 @@ app.use("*", async (c, next) => { const projectId = c.req.header("X-Project-ID") ?? null; const userAgent = c.req.header("User-Agent")?.slice(0, 255) ?? null; - const ip = c.req.header("X-Forwarded-For")?.split(",")[0] ?? null; + const ip = c.req.header("X-Real-IP") ?? null; // Fire-and-forget log insert (don't await, don't fail requests on log error) getPool() diff --git a/packages/server/src/lib/audit.ts b/packages/server/src/lib/audit.ts index 101997c..695844b 100644 --- a/packages/server/src/lib/audit.ts +++ b/packages/server/src/lib/audit.ts @@ -71,7 +71,5 @@ export async function writeAuditLog(entry: AuditEntry): Promise { // Helper: extract IP from Hono context export function getClientIp(headers: Headers): string { - return ( - headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? headers.get("x-real-ip") ?? "unknown" - ); + return headers.get("x-real-ip") ?? headers.get("x-forwarded-for")?.split(",").pop()?.trim() ?? "unknown"; } diff --git a/packages/server/src/lib/auth.ts b/packages/server/src/lib/auth.ts index aeec770..74e0806 100644 --- a/packages/server/src/lib/auth.ts +++ b/packages/server/src/lib/auth.ts @@ -1,6 +1,8 @@ import bcrypt from "bcryptjs"; +import { randomUUID } from "crypto"; import { SignJWT, jwtVerify } from "jose"; import type { Pool } from "pg"; +import { getPool } from "./db"; import { validateEnv } from "./env"; const getSecret = () => { @@ -8,7 +10,7 @@ const getSecret = () => { return new TextEncoder().encode(env.BETTERBASE_JWT_SECRET); }; -const TOKEN_EXPIRY = "30d"; +const TOKEN_EXPIRY = "8h"; const BCRYPT_ROUNDS = 12; // --- Password --- @@ -24,21 +26,67 @@ export async function verifyPassword(password: string, hash: string): Promise { + const env = validateEnv(); return new SignJWT({ sub: adminUserId, type: "admin" }) .setProtectedHeader({ alg: "HS256" }) .setIssuedAt() .setExpirationTime(TOKEN_EXPIRY) + .setIssuer(env.BETTERBASE_JWT_ISSUER) + .setAudience(env.BETTERBASE_JWT_AUDIENCE) + .setJti(randomUUID()) .sign(getSecret()); } -export async function verifyAdminToken(token: string): Promise<{ sub: string } | null> { +export async function verifyAdminToken( + token: string, +): Promise<{ sub: string; jti: string | undefined; exp?: number } | null> { + // Step 1: Verify JWT signature and payload validity + let payload: any; try { - const { payload } = await jwtVerify(token, getSecret()); + const env = validateEnv(); + const verified = await jwtVerify(token, getSecret(), { + issuer: env.BETTERBASE_JWT_ISSUER, + audience: env.BETTERBASE_JWT_AUDIENCE, + }); + payload = verified.payload; + if (payload.type !== "admin") return null; - return { sub: payload.sub as string }; + if (!payload.sub) return null; + + // Log warning if jti is missing + if (!payload.jti) { + console.warn(`[auth] Token missing jti claim (sub=${payload.sub})`); + } } catch { + // Cryptographic verification failed - invalid token return null; } + + // Step 2: Check revocation list (fail-closed on DB errors) + // Skip revocation check if jti is missing + if (payload.jti) { + try { + const pool = getPool(); + const { rows } = await pool.query( + "SELECT 1 FROM betterbase_meta.revoked_admin_tokens WHERE jti = $1 LIMIT 1", + [payload.jti], + ); + if (rows.length > 0) return null; + } catch (err) { + // DB/query error - log and fail closed + console.error( + `[auth] DB error checking token revocation (jti=${payload.jti}, sub=${payload.sub}):`, + err, + ); + return null; + } + } + + return { + sub: payload.sub as string, + jti: payload.jti as string | undefined, + exp: payload.exp as number | undefined + }; } // --- Middleware helper: extract + verify token from Authorization header --- diff --git a/packages/server/src/lib/env.ts b/packages/server/src/lib/env.ts index eb6afa9..276892c 100644 --- a/packages/server/src/lib/env.ts +++ b/packages/server/src/lib/env.ts @@ -17,6 +17,8 @@ const EnvSchema = z.object({ INNGEST_SIGNING_KEY: z.string().optional(), INNGEST_EVENT_KEY: z.string().optional(), PORT: z.string().default("3000"), + BETTERBASE_JWT_ISSUER: z.string().default("betterbase"), + BETTERBASE_JWT_AUDIENCE: z.string().default("betterbase-admin"), }); export type Env = z.infer; @@ -53,6 +55,15 @@ export function validateEnv(): Env { result.data.INNGEST_EVENT_KEY = "betterbase-dev-event-key"; } + if (result.data.STORAGE_ENDPOINT) { + if (!result.data.STORAGE_ACCESS_KEY || !result.data.STORAGE_SECRET_KEY) { + console.error( + "[env] STORAGE_ACCESS_KEY and STORAGE_SECRET_KEY are required when STORAGE_ENDPOINT is set", + ); + process.exit(1); + } + } + validatedEnv = result.data; return validatedEnv; } diff --git a/packages/server/src/lib/inngest.ts b/packages/server/src/lib/inngest.ts index b1a54fa..1c3f02b 100644 --- a/packages/server/src/lib/inngest.ts +++ b/packages/server/src/lib/inngest.ts @@ -1,21 +1,6 @@ import { createHash } from "node:crypto"; import { EventSchemas, Inngest } from "inngest"; - -// ─── CSV Escaping Helper ─────────────────────────────────────────────────────── -// Helper to escape CSV values - prevents CSV injection and handles special characters -const escapeCSVValue = (value: unknown): string => { - if (value === null || value === undefined) return ""; - const str = String(value); - // Prefix formula injection characters (=, +, -, @, \t, \r, \n) with single quote - if (str.match(/^[=\+\-@\t\r\n]/)) { - return `"${str.replace(/"/g, '""')}"`; - } - // Wrap in quotes if contains comma, quote, or newline - if (str.includes(",") || str.includes('"') || str.includes("\n") || str.includes("\r")) { - return `"${str.replace(/"/g, '""')}"`; - } - return str; -}; +import { escapeCSVValue } from "./csv"; // Helper to validate schema name - prevents SQL injection const validateSchemaName = (slug: string): string => { @@ -556,6 +541,29 @@ export const pollNotificationRules = inngest.createFunction( }, ); +// ─── Function: Cleanup Expired Revoked Tokens (Cron) ───────────────────────── + +export const cleanupExpiredTokens = inngest.createFunction( + { + id: "cleanup-expired-revoked-tokens", + retries: 1, + }, + // Runs every hour + { cron: "0 * * * *" }, + async ({ step }) => { + const deleted = await step.run("delete-expired-tokens", async () => { + const { getPool } = await import("./db"); + const pool = getPool(); + const { rowCount } = await pool.query( + "DELETE FROM betterbase_meta.revoked_admin_tokens WHERE expires_at IS NOT NULL AND expires_at < NOW()", + ); + return rowCount ?? 0; + }); + + return { deleted }; + }, +); + // ─── All functions (used in serve() registration) ──────────────────────────── export const allInngestFunctions = [ @@ -563,4 +571,5 @@ export const allInngestFunctions = [ evaluateNotificationRule, exportProjectUsers, pollNotificationRules, + cleanupExpiredTokens, ]; diff --git a/packages/server/src/routes/admin/auth.ts b/packages/server/src/routes/admin/auth.ts index 3de2d75..62dc5ef 100644 --- a/packages/server/src/routes/admin/auth.ts +++ b/packages/server/src/routes/admin/auth.ts @@ -1,6 +1,7 @@ import { zValidator } from "@hono/zod-validator"; import { Hono } from "hono"; import { z } from "zod"; +import { getClientIp, writeAuditLog } from "../../lib/audit"; import { extractBearerToken, signAdminToken, @@ -62,8 +63,40 @@ authRoutes.get("/me", async (c) => { return c.json({ admin: rows[0] }); }); -// POST /admin/auth/logout (client-side token discard — stateless) -authRoutes.post("/logout", (c) => c.json({ success: true })); +// POST /admin/auth/logout +authRoutes.post("/logout", async (c) => { + const token = extractBearerToken(c.req.header("Authorization")); + if (!token) return c.json({ success: true }); + + const payload = await verifyAdminToken(token); + if (!payload) return c.json({ success: true }); + + const pool = getPool(); + // Only revoke if jti is present + if (payload.jti && payload.exp) { + await pool.query( + `INSERT INTO betterbase_meta.revoked_admin_tokens (jti, admin_user_id, expires_at) + VALUES ($1, $2, to_timestamp($3)) + ON CONFLICT (jti) DO NOTHING`, + [payload.jti, payload.sub, payload.exp], + ); + } + + const { rows } = await pool.query("SELECT id, email FROM betterbase_meta.admin_users WHERE id = $1", [ + payload.sub, + ]); + if (rows.length > 0) { + await writeAuditLog({ + actorId: rows[0].id, + actorEmail: rows[0].email, + action: "admin.logout", + ipAddress: getClientIp(c.req.raw.headers), + userAgent: c.req.header("User-Agent") ?? undefined, + }); + } + + return c.json({ success: true }); +}); // GET /admin/auth/setup-status — check if admin exists (no body validation) authRoutes.get("/setup-status", async (c) => { diff --git a/packages/server/src/routes/admin/project-scoped/users.ts b/packages/server/src/routes/admin/project-scoped/users.ts index 9acfc00..1b719d5 100644 --- a/packages/server/src/routes/admin/project-scoped/users.ts +++ b/packages/server/src/routes/admin/project-scoped/users.ts @@ -2,6 +2,7 @@ import { zValidator } from "@hono/zod-validator"; import { Hono } from "hono"; import { z } from "zod"; import { getClientIp, writeAuditLog } from "../../../lib/audit"; +import { escapeCSVValue } from "../../../lib/csv"; import { getPool } from "../../../lib/db"; export const projectUserRoutes = new Hono(); @@ -233,14 +234,16 @@ projectUserRoutes.post("/export", async (c) => { header + rows .map( - (r) => `${r.id},"${r.name}","${r.email}",${r.email_verified},${r.created_at},${r.banned}`, + (r) => + `${escapeCSVValue(r.id)},${escapeCSVValue(r.name)},${escapeCSVValue(r.email)},${r.email_verified},${escapeCSVValue(r.created_at)},${r.banned}`, ) .join("\n"); return new Response(csv, { headers: { - "Content-Type": "text/csv", + "Content-Type": "text/csv; charset=utf-8", "Content-Disposition": `attachment; filename="users-${project.slug}-${Date.now()}.csv"`, + "Content-Security-Policy": "default-src 'none'", }, }); }); diff --git a/packages/server/src/routes/betterbase/index.ts b/packages/server/src/routes/betterbase/index.ts index cd16806..540ffe0 100644 --- a/packages/server/src/routes/betterbase/index.ts +++ b/packages/server/src/routes/betterbase/index.ts @@ -5,6 +5,7 @@ import { formatError, lookupFunction, } from "@betterbase/core"; +import { zValidator } from "@hono/zod-validator"; import { Hono } from "hono"; import { nanoid } from "nanoid"; import { z } from "zod"; @@ -20,6 +21,15 @@ import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; export const betterbaseRouter = new Hono(); +const SAFE_PROJECT_SLUG = /^[a-z][a-z0-9-]{0,62}$/; +const ALLOWED_UPLOAD_CONTENT_TYPES = new Set([ + "image/jpeg", + "image/png", + "image/webp", + "image/gif", + "application/pdf", + "text/plain", +]); // All function calls: POST /betterbase/:kind/* betterbaseRouter.post("/:kind/*", async (c) => { @@ -48,11 +58,15 @@ betterbaseRouter.post("/:kind/*", async (c) => { // Auth context const token = extractBearerToken(c.req.header("Authorization")); const adminPayload = token ? await verifyAdminToken(token) : null; - const authCtx = { userId: adminPayload?.sub ?? null, token }; + if (!adminPayload) return c.json({ error: "Unauthorized" }, 401); + const authCtx = { userId: adminPayload.sub, token }; // Build DB context const pool = getPool(); const projectSlug = c.req.header("X-Project-Slug") ?? "default"; + if (!SAFE_PROJECT_SLUG.test(projectSlug)) { + return c.json({ error: "Invalid project slug" }, 400); + } const dbSchema = `project_${projectSlug}`; try { @@ -93,12 +107,18 @@ betterbaseRouter.post("/:kind/*", async (c) => { // Storage context builder function buildStorageCtx(pool: any, projectSlug: string): StorageCtx { const env = validateEnv(); + + // Storage endpoint and credentials are required (validated in env.ts) + if (!env.STORAGE_ENDPOINT || !env.STORAGE_ACCESS_KEY || !env.STORAGE_SECRET_KEY) { + throw new Error("Storage not configured: missing STORAGE_ENDPOINT, STORAGE_ACCESS_KEY, or STORAGE_SECRET_KEY"); + } + return new StorageCtx({ pool, projectSlug, - endpoint: env.STORAGE_ENDPOINT ?? "http://minio:9000", - accessKey: env.STORAGE_ACCESS_KEY ?? "minioadmin", - secretKey: env.STORAGE_SECRET_KEY ?? "minioadmin", + endpoint: env.STORAGE_ENDPOINT, + accessKey: env.STORAGE_ACCESS_KEY, + secretKey: env.STORAGE_SECRET_KEY, bucket: env.STORAGE_BUCKET ?? "betterbase", publicBase: env.STORAGE_PUBLIC_BASE, }); @@ -176,47 +196,104 @@ function buildActionCtx(pool: any, dbSchema: string, auth: any, projectSlug: str } // Direct browser upload endpoint: POST /betterbase/storage/generate-upload-url -betterbaseRouter.post("/storage/generate-upload-url", async (c) => { - const { contentType, filename } = await c.req.json(); - const projectSlug = c.req.header("X-Project-Slug") ?? "default"; - const storageId = `st_${nanoid(20)}`; - const ext = filename?.split(".").pop() ?? ""; - const s3Key = `project_${projectSlug}/${storageId}${ext ? "." + ext : ""}`; - const env = validateEnv(); +betterbaseRouter.post( + "/storage/generate-upload-url", + zValidator( + "json", + z.object({ + contentType: z.string(), + filename: z.string(), + }), + ), + async (c) => { + const token = extractBearerToken(c.req.header("Authorization")); + const adminPayload = token ? await verifyAdminToken(token) : null; + if (!adminPayload) return c.json({ error: "Unauthorized" }, 401); - const s3 = new S3Client({ - endpoint: env.STORAGE_ENDPOINT ?? "http://minio:9000", - region: "us-east-1", - credentials: { - accessKeyId: env.STORAGE_ACCESS_KEY ?? "minioadmin", - secretAccessKey: env.STORAGE_SECRET_KEY ?? "minioadmin", - }, - forcePathStyle: true, - }); + const { contentType, filename } = c.req.valid("json"); - const uploadUrl = await getSignedUrl( - s3, - new PutObjectCommand({ - Bucket: env.STORAGE_BUCKET ?? "betterbase", - Key: s3Key, - ContentType: contentType ?? "application/octet-stream", - }), - { expiresIn: 300 }, - ); + // Validate contentType against allowlist + if (!ALLOWED_UPLOAD_CONTENT_TYPES.has(contentType)) { + return c.json({ error: "Unsupported content type" }, 400); + } - // Record the pending upload in the DB so getUrl() works after upload - const pool = getPool(); - await pool.query( - `INSERT INTO "project_${projectSlug}"._iac_storage + const projectSlug = c.req.header("X-Project-Slug") ?? "default"; + if (!SAFE_PROJECT_SLUG.test(projectSlug)) { + return c.json({ error: "Invalid project slug" }, 400); + } + + const storageId = `st_${nanoid(20)}`; + + // Safely extract and validate file extension + let ext = ""; + try { + const trimmedFilename = filename.trim(); + if (!trimmedFilename) { + return c.json({ error: "Invalid filename" }, 400); + } + + const parts = trimmedFilename.split("."); + if (parts.length > 1) { + const rawExt = parts[parts.length - 1]; + // Validate extension: no path separators, no query params, max 16 chars + if ( + rawExt.includes("/") || + rawExt.includes("?") || + rawExt.includes("\\") || + rawExt.length > 16 + ) { + ext = ""; // Fall back to no extension + } else { + ext = rawExt; + } + } + } catch (err) { + return c.json({ error: "Invalid filename" }, 400); + } + + const s3Key = `project_${projectSlug}/${storageId}${ext ? "." + ext : ""}`; + + // Ensure s3Key has no path separators beyond the expected structure + if (s3Key.split("/").length > 2 || s3Key.includes("..")) { + return c.json({ error: "Invalid filename" }, 400); + } + + const env = validateEnv(); + + // Validate storage credentials are configured + if (!env.STORAGE_ENDPOINT || !env.STORAGE_ACCESS_KEY || !env.STORAGE_SECRET_KEY) { + return c.json({ error: "Storage not configured" }, 500); + } + + const s3 = new S3Client({ + endpoint: env.STORAGE_ENDPOINT, + region: "us-east-1", + credentials: { + accessKeyId: env.STORAGE_ACCESS_KEY, + secretAccessKey: env.STORAGE_SECRET_KEY, + }, + forcePathStyle: true, + }); + + const uploadUrl = await getSignedUrl( + s3, + new PutObjectCommand({ + Bucket: env.STORAGE_BUCKET ?? "betterbase", + Key: s3Key, + ContentType: contentType, + }), + { expiresIn: 300 }, + ); + + // Record the pending upload in the DB so getUrl() works after upload + const pool = getPool(); + await pool.query( + `INSERT INTO "project_${projectSlug}"._iac_storage (storage_id, s3_key, bucket, content_type) VALUES ($1, $2, $3, $4) ON CONFLICT (storage_id) DO NOTHING`, - [ - storageId, - s3Key, - env.STORAGE_BUCKET ?? "betterbase", - contentType ?? "application/octet-stream", - ], - ); - - return c.json({ storageId, uploadUrl }); -}); + [storageId, s3Key, env.STORAGE_BUCKET ?? "betterbase", contentType], + ); + + return c.json({ storageId, uploadUrl }); + }, +); diff --git a/packages/server/src/routes/betterbase/ws.ts b/packages/server/src/routes/betterbase/ws.ts index 7b93906..8ef49c6 100644 --- a/packages/server/src/routes/betterbase/ws.ts +++ b/packages/server/src/routes/betterbase/ws.ts @@ -5,6 +5,7 @@ import { subscriptionTracker, } from "@betterbase/core"; import { nanoid } from "nanoid"; +import { verifyAdminToken } from "../../lib/auth"; const HEARTBEAT_INTERVAL_MS = 15_000; // ping every 15s const HEARTBEAT_TIMEOUT_MS = 30_000; // disconnect after 30s without pong @@ -137,11 +138,27 @@ export function getWSStats() { /** Mount in Bun.serve() options */ export function getBunServeConfig() { return { - fetch(req: Request, server: any) { + async fetch(req: Request, server: any) { const url = new URL(req.url); if (url.pathname === "/betterbase/ws") { + const token = url.searchParams.get("token"); + const payload = token ? await verifyAdminToken(token) : null; + if (!payload) return new Response("Unauthorized", { status: 401 }); + const projectSlug = url.searchParams.get("project") ?? "default"; - const upgraded = server.upgrade(req, { data: { projectSlug } }); + + // Validate that the verified payload is authorized for the requested project + // For admin tokens, we allow access to any project + // Additional scope/role checks could be added here if needed + + // Scrub the token from URL searchParams before upgrade to prevent logging + const scrubbedUrl = new URL(req.url); + scrubbedUrl.searchParams.delete("token"); + const scrubbedReq = new Request(scrubbedUrl, req); + + const upgraded = server.upgrade(scrubbedReq, { + data: { projectSlug, userId: payload.sub }, + }); if (upgraded) return undefined; return new Response("WebSocket upgrade failed", { status: 400 }); } diff --git a/packages/server/src/routes/device/index.ts b/packages/server/src/routes/device/index.ts index bbff6ee..32d23da 100644 --- a/packages/server/src/routes/device/index.ts +++ b/packages/server/src/routes/device/index.ts @@ -9,9 +9,46 @@ import { validateEnv } from "../../lib/env"; export const deviceRouter = new Hono(); const CODE_EXPIRY_MINUTES = 10; +const DEVICE_CODE_RATE_LIMIT_WINDOW_MS = 60_000; +const DEVICE_CODE_RATE_LIMIT_MAX = 5; +const RATE_LIMIT_MAP_MAX_SIZE = 10_000; // Cap map size to prevent unbounded growth +const deviceCodeRateLimits = new Map(); // POST /device/code — CLI calls this to initiate login deviceRouter.post("/code", async (c) => { + // Derive safer client key: prefer X-Real-IP, fallback to connection info + // Avoid grouping all header-less requests under literal "unknown" + const xRealIp = c.req.header("X-Real-IP"); + const ip = xRealIp || `fallback-${c.req.raw.headers.get("cf-connecting-ip") || "unknown"}`; + + const now = Date.now(); + const timestamps = deviceCodeRateLimits.get(ip) ?? []; + const recent = timestamps.filter((ts) => now - ts < DEVICE_CODE_RATE_LIMIT_WINDOW_MS); + + if (recent.length >= DEVICE_CODE_RATE_LIMIT_MAX) { + return c.json({ error: "Rate limit exceeded. Try again in a minute." }, 429); + } + + recent.push(now); + + // Prune entry if empty (cleanup), otherwise update + if (recent.length === 0) { + deviceCodeRateLimits.delete(ip); + } else { + deviceCodeRateLimits.set(ip, recent); + } + + // Cap the map size to prevent unbounded growth + if (deviceCodeRateLimits.size > RATE_LIMIT_MAP_MAX_SIZE) { + // Remove oldest entries + const entries = Array.from(deviceCodeRateLimits.entries()); + entries.sort((a, b) => Math.min(...a[1]) - Math.min(...b[1])); + const toDelete = entries.slice(0, Math.floor(RATE_LIMIT_MAP_MAX_SIZE * 0.1)); + for (const [key] of toDelete) { + deviceCodeRateLimits.delete(key); + } + } + const pool = getPool(); const deviceCode = nanoid(32);