Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions docker/nginx/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Storage (MinIO)
location /storage/ {
rewrite ^/storage/(.*) /$1 break;
Expand All @@ -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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand All @@ -107,12 +133,12 @@ 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;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
}
}
}
}
9 changes: 9 additions & 0 deletions packages/server/migrations/017_revoked_admin_tokens.sql
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -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;
8 changes: 8 additions & 0 deletions packages/server/migrations/019_rate_limits.sql
Original file line number Diff line number Diff line change
@@ -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);
11 changes: 10 additions & 1 deletion packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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) {
Expand All @@ -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()
Expand Down
10 changes: 7 additions & 3 deletions packages/server/src/lib/audit.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHash } from "crypto";
import type { Pool } from "pg";
import { getPool } from "./db";

Expand All @@ -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"
Expand Down Expand Up @@ -71,7 +73,9 @@ export async function writeAuditLog(entry: AuditEntry): Promise<void> {

// 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}`;
}
83 changes: 79 additions & 4 deletions packages/server/src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,42 @@
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 = () => {
const env = validateEnv();
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<string, RevocationCacheEntry>();

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 ---

Expand All @@ -24,21 +51,69 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
// --- JWT for admin sessions ---

export async function signAdminToken(adminUserId: string): Promise<string> {
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 };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// --- Middleware helper: extract + verify token from Authorization header ---
Expand Down
17 changes: 17 additions & 0 deletions packages/server/src/lib/csv.ts
Original file line number Diff line number Diff line change
@@ -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;
}
8 changes: 5 additions & 3 deletions packages/server/src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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<typeof EnvSchema>;
Expand Down
17 changes: 1 addition & 16 deletions packages/server/src/lib/inngest.ts
Original file line number Diff line number Diff line change
@@ -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 => {
Expand Down
Loading
Loading