diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf index c91d012..06174f4 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -3,6 +3,17 @@ events { } http { + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + # Trust private-network reverse proxies; adjust for your CDN/ALB CIDRs in production. + set_real_ip_from 10.0.0.0/8; + set_real_ip_from 172.16.0.0/12; + set_real_ip_from 192.168.0.0/16; + real_ip_header X-Forwarded-For; + upstream betterbase_server { server betterbase-server:3001; } @@ -68,10 +79,24 @@ http { location /health { proxy_pass http://betterbase_server; + proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } + location /betterbase/ { + proxy_pass http://betterbase_server; + proxy_http_version 1.1; + 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-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 3600s; + client_max_body_size 100m; + } + # Storage (MinIO) location /storage/ { rewrite ^/storage/(.*) /$1 break; @@ -98,6 +123,7 @@ http { location @dashboard_fallback { 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-Proto $scheme; } @@ -107,7 +133,7 @@ http { proxy_pass http://betterbase_server; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; + proxy_set_header Connection $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; @@ -115,4 +141,4 @@ http { proxy_read_timeout 3600s; } } -} \ No newline at end of file +} 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/migrations/018_revoked_admin_tokens_expiry_hardening.sql b/packages/server/migrations/018_revoked_admin_tokens_expiry_hardening.sql new file mode 100644 index 0000000..ef41621 --- /dev/null +++ b/packages/server/migrations/018_revoked_admin_tokens_expiry_hardening.sql @@ -0,0 +1,6 @@ +UPDATE betterbase_meta.revoked_admin_tokens +SET expires_at = NOW() + INTERVAL '8 hours' +WHERE expires_at IS NULL; + +ALTER TABLE betterbase_meta.revoked_admin_tokens +ALTER COLUMN expires_at SET NOT NULL; diff --git a/packages/server/migrations/019_rate_limits.sql b/packages/server/migrations/019_rate_limits.sql new file mode 100644 index 0000000..2551e5a --- /dev/null +++ b/packages/server/migrations/019_rate_limits.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS betterbase_meta.rate_limits ( + key TEXT PRIMARY KEY, + count INTEGER NOT NULL, + expires_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_rate_limits_expires_at + ON betterbase_meta.rate_limits (expires_at); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 6fc5829..7203e72 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -2,6 +2,7 @@ import { Hono } from "hono"; import { cors } from "hono/cors"; import { logger } from "hono/logger"; import { serve } from "inngest/hono"; +import { getClientIp } from "./lib/audit"; import { getPool } from "./lib/db"; import { validateEnv } from "./lib/env"; import { allInngestFunctions, inngest } from "./lib/inngest"; @@ -16,6 +17,14 @@ const env = validateEnv(); // Bootstrap const pool = getPool(); await runMigrations(pool); +// Cleanup revoked tokens on interval (fire-and-forget) +setInterval( + () => + getPool() + .query("DELETE FROM betterbase_meta.revoked_admin_tokens WHERE expires_at < NOW()") + .catch((err) => console.error("[auth] Failed revoked token cleanup:", err)), + 60 * 60 * 1000, +); // Seed initial admin if env vars provided and no admin exists if (env.BETTERBASE_ADMIN_EMAIL && env.BETTERBASE_ADMIN_PASSWORD) { @@ -36,7 +45,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 = getClientIp(c.req.raw.headers); // 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..32dcf20 100644 --- a/packages/server/src/lib/audit.ts +++ b/packages/server/src/lib/audit.ts @@ -1,3 +1,4 @@ +import { createHash } from "crypto"; import type { Pool } from "pg"; import { getPool } from "./db"; @@ -12,6 +13,7 @@ export type AuditAction = | "project.user.ban" | "project.user.unban" | "project.user.delete" + | "project.user.export" | "project.user.import" | "webhook.create" | "webhook.update" @@ -71,7 +73,9 @@ 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" - ); + const fromProxy = headers.get("x-real-ip") ?? headers.get("x-forwarded-for")?.split(",")[0]?.trim(); + if (fromProxy) return fromProxy; + const ua = headers.get("user-agent") ?? ""; + const fp = createHash("sha256").update(ua).digest("hex").slice(0, 16); + return `ua:${fp}`; } diff --git a/packages/server/src/lib/auth.ts b/packages/server/src/lib/auth.ts index aeec770..cffae71 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,8 +10,33 @@ const getSecret = () => { return new TextEncoder().encode(env.BETTERBASE_JWT_SECRET); }; -const TOKEN_EXPIRY = "30d"; +const TOKEN_EXPIRY = "8h"; const BCRYPT_ROUNDS = 12; +const NEGATIVE_CACHE_TTL_MS = 60_000; +const REVOCATION_CACHE_MAX = 10_000; + +type RevocationCacheEntry = { revoked: boolean; expiresAtMs: number }; +const revocationCache = new Map(); + +function getCachedRevocation(jti: string): boolean | null { + const entry = revocationCache.get(jti); + if (!entry) return null; + if (entry.expiresAtMs <= Date.now()) { + revocationCache.delete(jti); + return null; + } + revocationCache.delete(jti); + revocationCache.set(jti, entry); + return entry.revoked; +} + +function setCachedRevocation(jti: string, revoked: boolean, expiresAtMs: number) { + revocationCache.set(jti, { revoked, expiresAtMs }); + if (revocationCache.size > REVOCATION_CACHE_MAX) { + const oldestKey = revocationCache.keys().next().value; + if (oldestKey) revocationCache.delete(oldestKey); + } +} // --- Password --- @@ -24,21 +51,69 @@ 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; exp?: number } | null> { + 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; } catch { return null; } + + const tokenJti = typeof payload.jti === "string" ? payload.jti : undefined; + const tokenExp = typeof payload.exp === "number" ? payload.exp : undefined; + + if (!tokenJti) return null; + + const cached = getCachedRevocation(tokenJti); + if (cached !== null) { + if (cached) return null; + return { sub: payload.sub as string, jti: tokenJti, exp: tokenExp }; + } + + try { + const pool = getPool(); + const { rows } = await pool.query<{ expires_at: string }>( + "SELECT expires_at FROM betterbase_meta.revoked_admin_tokens WHERE jti = $1 LIMIT 1", + [tokenJti], + ); + if (rows.length > 0) { + const revokedExpiry = new Date(rows[0].expires_at).getTime(); + setCachedRevocation(tokenJti, true, Number.isNaN(revokedExpiry) ? Date.now() + NEGATIVE_CACHE_TTL_MS : revokedExpiry); + return null; + } + const negativeTtl = Math.min( + tokenExp ? Math.max(tokenExp * 1000 - Date.now(), 5_000) : NEGATIVE_CACHE_TTL_MS, + NEGATIVE_CACHE_TTL_MS, + ); + setCachedRevocation(tokenJti, false, Date.now() + negativeTtl); + return { sub: payload.sub as string, jti: tokenJti, exp: tokenExp }; + } catch (error) { + console.error( + `[auth] Failed to check token revocation for sub=${payload.sub} jti=${tokenJti}:`, + error, + ); + return null; + } } // --- Middleware helper: extract + verify token from Authorization header --- diff --git a/packages/server/src/lib/csv.ts b/packages/server/src/lib/csv.ts new file mode 100644 index 0000000..238e852 --- /dev/null +++ b/packages/server/src/lib/csv.ts @@ -0,0 +1,17 @@ +const DANGEROUS_CSV_PREFIX = /^[=+\-@\t\r\n]/; + +export function escapeCSVValue(value: unknown): string { + if (value === null || value === undefined) return ""; + const str = String(value); + const escaped = str.replace(/"/g, '""'); + + if (DANGEROUS_CSV_PREFIX.test(str)) { + return `"${`'${escaped}`}"`; + } + + if (str.includes(",") || str.includes('"') || str.includes("\n") || str.includes("\r")) { + return `"${escaped}"`; + } + + return str; +} diff --git a/packages/server/src/lib/env.ts b/packages/server/src/lib/env.ts index eb6afa9..7b82999 100644 --- a/packages/server/src/lib/env.ts +++ b/packages/server/src/lib/env.ts @@ -6,9 +6,9 @@ const EnvSchema = z.object({ BETTERBASE_ADMIN_EMAIL: z.string().email().optional(), BETTERBASE_ADMIN_PASSWORD: z.string().min(8).optional(), NODE_ENV: z.enum(["development", "production", "test"]).default("development"), - STORAGE_ENDPOINT: z.string().optional(), - STORAGE_ACCESS_KEY: z.string().optional(), - STORAGE_SECRET_KEY: z.string().optional(), + STORAGE_ENDPOINT: z.string().min(1).optional(), + STORAGE_ACCESS_KEY: z.string().min(1).optional(), + STORAGE_SECRET_KEY: z.string().min(1).optional(), STORAGE_BUCKET: z.string().default("betterbase"), STORAGE_PUBLIC_BASE: z.string().url().optional(), CORS_ORIGINS: z.string().default("http://localhost:3000"), @@ -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; diff --git a/packages/server/src/lib/inngest.ts b/packages/server/src/lib/inngest.ts index b1a54fa..cd95c95 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 => { diff --git a/packages/server/src/routes/admin/auth.ts b/packages/server/src/routes/admin/auth.ts index 3de2d75..0136078 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, @@ -10,6 +11,13 @@ import { import { getPool } from "../../lib/db"; export const authRoutes = new Hono(); +const LOGOUT_RATE_LIMIT_WINDOW_MS = 60_000; +const LOGOUT_RATE_LIMIT_MAX = 20; +const logoutRateLimits = new Map(); + +function getLogoutRateLimitKey(c: any): string { + return getClientIp(c.req.raw.headers); +} // POST /admin/auth/login authRoutes.post( @@ -62,8 +70,50 @@ 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 key = getLogoutRateLimitKey(c); + const now = Date.now(); + const recent = (logoutRateLimits.get(key) ?? []).filter( + (ts) => now - ts < LOGOUT_RATE_LIMIT_WINDOW_MS, + ); + if (recent.length >= LOGOUT_RATE_LIMIT_MAX) { + return c.json({ error: "Too many logout attempts" }, 429); + } + recent.push(now); + logoutRateLimits.set(key, recent); + + 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(); + if (payload.jti) { + 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 ?? Math.floor(Date.now() / 1000)], + ); + } + + const { rows } = await pool.query("SELECT id, email FROM betterbase_meta.admin_users WHERE id = $1", [ + payload.sub, + ]); + if (rows.length > 0) { + 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..9d08abb 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(); @@ -222,6 +223,7 @@ projectUserRoutes.get("/stats/overview", async (c) => { projectUserRoutes.post("/export", async (c) => { const pool = getPool(); const project = c.get("project") as { id: string; slug: string }; + const admin = c.get("adminUser") as { id: string; email: string }; const s = schemaName(project); const { rows } = await pool.query( @@ -233,14 +235,38 @@ 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), + escapeCSVValue(r.email_verified), + escapeCSVValue(r.created_at), + escapeCSVValue(r.banned), + ].join(","), ) .join("\n"); + writeAuditLog({ + actorId: admin.id, + actorEmail: admin.email, + action: "project.user.export", + resourceType: "project", + resourceId: project.id, + resourceName: project.slug, + afterData: { + exported_count: rows.length, + fields: ["id", "name", "email", "email_verified", "created_at", "banned"], + }, + ipAddress: getClientIp(c.req.raw.headers), + userAgent: c.req.header("User-Agent") ?? undefined, + }); + 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/admin/storage.ts b/packages/server/src/routes/admin/storage.ts index d5a0e6f..e3d08b9 100644 --- a/packages/server/src/routes/admin/storage.ts +++ b/packages/server/src/routes/admin/storage.ts @@ -8,14 +8,19 @@ import { import { zValidator } from "@hono/zod-validator"; import { Hono } from "hono"; import { z } from "zod"; +import { validateEnv } from "../../lib/env"; function getS3Client(): S3Client { + const env = validateEnv(); + if (!env.STORAGE_ENDPOINT || !env.STORAGE_ACCESS_KEY || !env.STORAGE_SECRET_KEY) { + throw new Error("Storage is not configured. Set STORAGE_ENDPOINT, STORAGE_ACCESS_KEY, STORAGE_SECRET_KEY."); + } return new S3Client({ - endpoint: process.env.STORAGE_ENDPOINT, + endpoint: env.STORAGE_ENDPOINT, region: "us-east-1", credentials: { - accessKeyId: process.env.STORAGE_ACCESS_KEY ?? "minioadmin", - secretAccessKey: process.env.STORAGE_SECRET_KEY ?? "minioadmin", + accessKeyId: env.STORAGE_ACCESS_KEY, + secretAccessKey: env.STORAGE_SECRET_KEY, }, forcePathStyle: true, // Required for MinIO }); diff --git a/packages/server/src/routes/betterbase/index.ts b/packages/server/src/routes/betterbase/index.ts index cd16806..db2d100 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"; @@ -13,13 +14,22 @@ import { getPool } from "../../lib/db"; import { validateEnv } from "../../lib/env"; // Import WS handler for stats -import { getWSStats } from "./ws"; +import { createWSTicket, getWSStats, WS_TICKET_TTL_MS } from "./ws"; // Import S3 utilities 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) => { @@ -30,6 +40,11 @@ betterbaseRouter.post("/:kind/*", async (c) => { const fn = lookupFunction(path); if (!fn) return c.json({ error: `Function not found: ${path}` }, 404); + const projectSlug = c.req.header("X-Project-Slug") ?? "default"; + if (!SAFE_PROJECT_SLUG.test(projectSlug)) { + return c.json({ error: "Invalid project slug" }, 400); + } + // Parse body let args: unknown; try { @@ -48,11 +63,11 @@ 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"; const dbSchema = `project_${projectSlug}`; try { @@ -93,13 +108,18 @@ betterbaseRouter.post("/:kind/*", async (c) => { // Storage context builder function buildStorageCtx(pool: any, projectSlug: string): StorageCtx { const env = validateEnv(); + if (!env.STORAGE_ENDPOINT || !env.STORAGE_ACCESS_KEY || !env.STORAGE_SECRET_KEY) { + throw new Error( + "Storage is not configured. Set STORAGE_ENDPOINT, STORAGE_ACCESS_KEY, 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", - bucket: env.STORAGE_BUCKET ?? "betterbase", + endpoint: env.STORAGE_ENDPOINT, + accessKey: env.STORAGE_ACCESS_KEY, + secretKey: env.STORAGE_SECRET_KEY, + bucket: env.STORAGE_BUCKET, publicBase: env.STORAGE_PUBLIC_BASE, }); } @@ -176,20 +196,55 @@ 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 uploadUrlSchema = z.object({ + contentType: z.string().min(1), + filename: z.string().min(1).max(255).optional(), +}); + +betterbaseRouter.post("/storage/generate-upload-url", zValidator("json", uploadUrlSchema), 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 { contentType, filename } = c.req.valid("json"); + const safeContentType = ALLOWED_UPLOAD_CONTENT_TYPES.has(contentType) ? contentType : null; + if (!safeContentType) return c.json({ error: "Unsupported content type" }, 400); + const projectSlug = c.req.header("X-Project-Slug") ?? "default"; + if (!SAFE_PROJECT_SLUG.test(projectSlug)) { + return c.json({ error: "Invalid project slug" }, 400); + } + + let ext = ""; + if (typeof filename === "string") { + // Original filename is not used in S3 keys; only a sanitized trailing extension is used. + const trimmed = filename.trim(); + if (trimmed.includes("/") || trimmed.includes("?")) { + return c.json({ error: "Invalid filename" }, 400); + } + const parsedExt = trimmed.includes(".") ? trimmed.split(".").pop() ?? "" : ""; + if (parsedExt && !/^[a-zA-Z0-9]{1,16}$/.test(parsedExt)) { + return c.json({ error: "Invalid filename extension" }, 400); + } + ext = parsedExt.toLowerCase(); + } + const storageId = `st_${nanoid(20)}`; - const ext = filename?.split(".").pop() ?? ""; const s3Key = `project_${projectSlug}/${storageId}${ext ? "." + ext : ""}`; const env = validateEnv(); + if (!env.STORAGE_ENDPOINT || !env.STORAGE_ACCESS_KEY || !env.STORAGE_SECRET_KEY) { + return c.json( + { error: "Storage is not configured. Set STORAGE_ENDPOINT, STORAGE_ACCESS_KEY, STORAGE_SECRET_KEY." }, + 500, + ); + } const s3 = new S3Client({ - endpoint: env.STORAGE_ENDPOINT ?? "http://minio:9000", + endpoint: env.STORAGE_ENDPOINT, region: "us-east-1", credentials: { - accessKeyId: env.STORAGE_ACCESS_KEY ?? "minioadmin", - secretAccessKey: env.STORAGE_SECRET_KEY ?? "minioadmin", + accessKeyId: env.STORAGE_ACCESS_KEY, + secretAccessKey: env.STORAGE_SECRET_KEY, }, forcePathStyle: true, }); @@ -197,9 +252,9 @@ betterbaseRouter.post("/storage/generate-upload-url", async (c) => { const uploadUrl = await getSignedUrl( s3, new PutObjectCommand({ - Bucket: env.STORAGE_BUCKET ?? "betterbase", + Bucket: env.STORAGE_BUCKET, Key: s3Key, - ContentType: contentType ?? "application/octet-stream", + ContentType: safeContentType, }), { expiresIn: 300 }, ); @@ -213,10 +268,35 @@ betterbaseRouter.post("/storage/generate-upload-url", async (c) => { [ storageId, s3Key, - env.STORAGE_BUCKET ?? "betterbase", - contentType ?? "application/octet-stream", + env.STORAGE_BUCKET, + safeContentType, ], ); return c.json({ storageId, uploadUrl }); }); + +betterbaseRouter.post( + "/ws-ticket", + zValidator("json", z.object({ projectSlug: z.string().min(1).max(63) })), + 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 { projectSlug } = c.req.valid("json"); + if (!SAFE_PROJECT_SLUG.test(projectSlug)) { + return c.json({ error: "Invalid project slug" }, 400); + } + + const pool = getPool(); + const { rows } = await pool.query( + "SELECT id FROM betterbase_meta.projects WHERE slug = $1 LIMIT 1", + [projectSlug], + ); + if (rows.length === 0) return c.json({ error: "Project not found" }, 404); + + const ticket = createWSTicket(adminPayload.sub, projectSlug); + return c.json({ ticket, expiresInMs: WS_TICKET_TTL_MS }); + }, +); diff --git a/packages/server/src/routes/betterbase/ws.ts b/packages/server/src/routes/betterbase/ws.ts index 7b93906..54645ff 100644 --- a/packages/server/src/routes/betterbase/ws.ts +++ b/packages/server/src/routes/betterbase/ws.ts @@ -16,8 +16,31 @@ interface ConnectedClient { lastPong: number; heartbeatTimer?: ReturnType; } +interface WSTicket { + adminUserId: string; + projectSlug: string; + expiresAt: number; +} const clients = new Map(); +const wsTickets = new Map(); +export const WS_TICKET_TTL_MS = 60_000; + +function pruneExpiredWSTickets() { + const now = Date.now(); + for (const [ticket, meta] of wsTickets.entries()) { + if (meta.expiresAt <= now) wsTickets.delete(ticket); + } +} + +setInterval(pruneExpiredWSTickets, Math.max(15_000, Math.floor(WS_TICKET_TTL_MS / 2))); + +export function createWSTicket(adminUserId: string, projectSlug: string): string { + pruneExpiredWSTickets(); + const ticket = nanoid(32); + wsTickets.set(ticket, { adminUserId, projectSlug, expiresAt: Date.now() + WS_TICKET_TTL_MS }); + return ticket; +} /** Bun WebSocket handler object — passed to Bun.serve() */ export const betterbaseWSHandler = { @@ -137,11 +160,31 @@ 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 ticket = url.searchParams.get("ticket"); + if (!ticket) return new Response("Unauthorized", { status: 401 }); + const wsTicket = wsTickets.get(ticket); + if (!wsTicket || wsTicket.expiresAt < Date.now()) { + wsTickets.delete(ticket); + return new Response("Unauthorized", { status: 401 }); + } + wsTickets.delete(ticket); + const projectSlug = url.searchParams.get("project") ?? "default"; - const upgraded = server.upgrade(req, { data: { projectSlug } }); + if (projectSlug !== wsTicket.projectSlug) { + return new Response("Forbidden", { status: 403 }); + } + + url.searchParams.delete("ticket"); + const sanitizedReq = new Request(url.toString(), { + method: req.method, + headers: req.headers, + }); + const upgraded = server.upgrade(sanitizedReq, { + data: { projectSlug, userId: wsTicket.adminUserId }, + }); 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..fe01ccf 100644 --- a/packages/server/src/routes/device/index.ts +++ b/packages/server/src/routes/device/index.ts @@ -1,17 +1,68 @@ import { zValidator } from "@hono/zod-validator"; -import { Hono } from "hono"; +import { createHash } from "crypto"; +import { Hono, type Context } from "hono"; import { nanoid } from "nanoid"; import { z } from "zod"; -import { signAdminToken } from "../../lib/auth"; +import { signAdminToken, verifyPassword } from "../../lib/auth"; import { getPool } from "../../lib/db"; 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 FALLBACK_PASSWORD_HASH = "$2a$12$KIXQ4Q0A9fU8A6.vW2YCwOp0q1fYQxN6v6M3fPazTA/gkGEXUXMa2"; + +function resolveClientIp(c: Context): string { + const trustProxy = process.env.TRUSTED_PROXY === "true"; + if (trustProxy) { + const realIp = c.req.header("X-Real-IP")?.trim(); + if (realIp && /^[a-fA-F0-9:.]+$/.test(realIp)) return realIp; + + const xff = c.req.header("X-Forwarded-For")?.split(",")[0]?.trim(); + if (xff && /^[a-fA-F0-9:.]+$/.test(xff)) return xff; + } + + const rawReq = c.req.raw as any; + return rawReq?.socket?.remoteAddress ?? rawReq?.connection?.remoteAddress ?? "unknown"; +} + +function getRateLimitKey(c: Context): string { + const ip = resolveClientIp(c); + const ua = c.req.header("User-Agent") ?? ""; + const fingerprint = createHash("sha256").update(ua).digest("hex").slice(0, 16); + return `ipfp:${ip}:${fingerprint}`; +} + +async function checkRateLimit(key: string): Promise { + const pool = getPool(); + const result = await pool.query<{ count: number }>( + `INSERT INTO betterbase_meta.rate_limits (key, count, expires_at) + VALUES ($1, 1, NOW() + ($2 * INTERVAL '1 millisecond')) + ON CONFLICT (key) DO UPDATE + SET count = CASE + WHEN betterbase_meta.rate_limits.expires_at <= NOW() THEN 1 + ELSE betterbase_meta.rate_limits.count + 1 + END, + expires_at = CASE + WHEN betterbase_meta.rate_limits.expires_at <= NOW() THEN NOW() + ($2 * INTERVAL '1 millisecond') + ELSE betterbase_meta.rate_limits.expires_at + END + RETURNING count`, + [key, DEVICE_CODE_RATE_LIMIT_WINDOW_MS], + ); + pool.query("DELETE FROM betterbase_meta.rate_limits WHERE expires_at <= NOW()").catch(() => {}); + return result.rows[0].count <= DEVICE_CODE_RATE_LIMIT_MAX; +} // POST /device/code — CLI calls this to initiate login deviceRouter.post("/code", async (c) => { + const key = getRateLimitKey(c); + if (!(await checkRateLimit(`device:code:${key}`))) { + return c.json({ error: "Rate limit exceeded. Try again in a minute." }, 429); + } + const pool = getPool(); const deviceCode = nanoid(32); @@ -68,6 +119,11 @@ deviceRouter.get("/verify", async (c) => { // POST /device/verify — Form submission deviceRouter.post("/verify", async (c) => { + const key = getRateLimitKey(c); + if (!(await checkRateLimit(`device:verify:${key}`))) { + return c.html(`

Invalid credentials.

`, 429); + } + const body = await c.req.parseBody(); const userCode = String(body.user_code ?? "") .toUpperCase() @@ -82,13 +138,12 @@ deviceRouter.post("/verify", async (c) => { "SELECT id, password_hash FROM betterbase_meta.admin_users WHERE email = $1", [email], ); - if (admins.length === 0) { + const hashToCheck = admins[0]?.password_hash ?? FALLBACK_PASSWORD_HASH; + const valid = await verifyPassword(password, hashToCheck); + if (!valid) { return c.html(`

Invalid credentials.

`); } - - const { verifyPassword } = await import("../../lib/auth"); - const valid = await verifyPassword(password, admins[0].password_hash); - if (!valid) { + if (admins.length === 0) { return c.html(`

Invalid credentials.

`); }