import express from 'express'; import compression from 'compression'; import cors from 'cors'; import helmet from 'helmet'; import path from 'path'; import { fileURLToPath } from 'url'; import { keysRouter } from './routes/keys.js'; import { clientProfilesRouter } from './routes/client-profiles.js'; import { conversationsRouter } from './routes/conversations.js'; import { logsRouter } from './routes/logs.js'; import { modelsRouter } from './routes/models.js'; import { proxyRouter } from './routes/proxy.js'; import { responsesRouter } from './routes/responses.js'; import { anthropicRouter } from './routes/anthropic.js'; import { fallbackRouter } from './routes/fallback.js'; import { profilesRouter } from './routes/profiles.js'; import { embeddingsRouter } from './routes/embeddings.js'; import { mediaRouter } from './routes/media.js'; import { analyticsRouter } from './routes/analytics.js'; import { healthRouter } from './routes/health.js'; import { freeTierRouter } from './routes/free-tier.js'; import { settingsRouter } from './routes/settings.js'; import { premiumRouter } from './routes/premium.js'; import { backupsRouter } from './routes/backups.js'; import { cacheRouter } from './routes/cache.js'; import { compressionRouter } from './routes/compression.js'; import { authRouter } from './routes/auth.js'; import { docsRouter } from './routes/docs.js'; import { mcpRouter } from './routes/mcp.js'; import { statusRouter, providersRouter } from './routes/status.js'; import { geminiRouter } from './routes/gemini.js'; import { ollamaRouter } from './routes/ollama.js'; import { urlTokenRouter } from './routes/url-tokens.js'; import { updateRouter } from './routes/update.js'; import { moderationRouter } from './routes/moderation.js'; import { requireAuth } from './middleware/requireAuth.js'; import { createProxyRateLimiter, createAdminRateLimiter } from './middleware/rateLimit.js'; // Password-guess ceiling for GET /api/keys/export. Deliberately low: a real // user exports keys occasionally, never ten times a minute. const EXPORT_RATE_LIMIT_RPM = 10; import { errorHandler } from './middleware/errorHandler.js'; import { clientContextMiddleware } from './lib/client-context.js'; import type { Config } from './lib/config.js'; import { loadConfig } from './lib/config.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DEFAULT_DASHBOARD_ORIGINS = [ 'http://localhost:5173', 'http://127.0.0.1:5173', 'http://[::1]:5173', ]; // SHA-256 of the inline theme/direction bootstrap in client/index.html, which // must run before first paint. Kept as a literal so the CSP header stays a // constant string; csp-inline-bootstrap.test.ts recomputes it from the real // index.html (source and build) and fails if the two ever drift apart. export const INLINE_BOOTSTRAP_SHA = "'sha256-4Mz/yZAENQGlTAAeE1WqXruXCripvlvl0s+Q9S1VS4A='"; // A build asset is safe to cache forever+immutable when its URL is // content-addressed. Vite parks every hashed chunk (JS, CSS, fonts, images) // under assets/, so that directory is the reliable signal; the -. // suffix is a belt-and-braces fallback for any hashed file emitted elsewhere. // index.html and other unhashed entries deliberately fall through to no-cache. const HASHED_ASSET_RE = /-[A-Za-z0-9_-]{6,}\.[A-Za-z0-9]+$/; function isImmutableAsset(filePath: string): boolean { return ( filePath.includes(`${path.sep}assets${path.sep}`) || HASHED_ASSET_RE.test(path.basename(filePath)) ); } // Loopback and *.localhost are "potentially trustworthy" origins even over // plain HTTP (https://www.w3.org/TR/powerful-features/#is-origin-trustworthy), // so a desktop/localhost install still gets the secure-context-only headers. const LOOPBACK_HOST_RE = /^(?:localhost|[^.]+(?:\.[^.]+)*\.localhost|127(?:\.\d{1,3}){3}|\[?::1\]?)$/i; // TLS terminated here (req.secure), or in front of us and forwarded. The // X-Forwarded-Proto check is the documented reverse-proxy setup for publishing // the proxy beyond localhost. function isHttpsRequest(req: express.Request): boolean { const forwardedProto = req.headers['x-forwarded-proto']; // A proxy chain sends a comma-separated list; the client-facing hop is first. const first = String(forwardedProto ?? '').split(',')[0]!.trim().toLowerCase(); return first === 'https' || req.secure; } // Whether the browser will treat this origin as a secure context. Headers that // only apply to secure contexts are worse than useless elsewhere: the browser // drops them and logs an error, which is exactly what a plain-HTTP LAN install // reported in #734. function isTrustworthyOrigin(req: express.Request): boolean { return isHttpsRequest(req) || LOOPBACK_HOST_RE.test(req.hostname ?? ''); } export function createApp(config?: Config) { const cfg = config ?? loadConfig(); const app = express(); // TRUST_PROXY (#1024): opt-in trust of X-Forwarded-* from a reverse proxy. // false (default) ignores forwarded headers so direct callers cannot spoof // the client IP; true trusts every hop; a comma-separated list of addresses // /CIDRs trusts only those proxies. Must be set before client-context and // the rate-limit middleware, which both resolve the client IP. app.set('trust proxy', cfg.trustProxy); const allowedCorsOrigins = new Set([ ...DEFAULT_DASHBOARD_ORIGINS, ...cfg.dashboardOrigins, ]); // CSP: default-src 'self' restricts content to the same origin. Scripts // are hashed by the Vite/React build, so 'self' works in production. Inline // styles from React hydration need 'unsafe-inline'. HSTS stays off because // this is a single-user local proxy served over HTTP (see README). // // `upgrade-insecure-requests` is emitted by Helmet by default; v0.6.6's // CSP hardening (#498) inherited it and broke HTTP LAN installs because the // browser rewrites /assets/* to https:// on an origin that has no TLS, // producing ERR_SSL_PROTOCOL_ERROR and a blank dashboard (#682). // The directive is dropped from the static Helmet config and re-added per // request below, gated by protocol + the CSP_UPGRADE_INSECURE_REQUESTS env. // // Cross-Origin-Opener-Policy and Origin-Agent-Cluster are handled the same // way, for the same reason: both only apply to secure contexts, so on a // plain-HTTP LAN origin the browser discards them and logs a console error // instead (#734). They are re-added per request whenever the origin is one // the browser trusts — HTTPS, or loopback, which covers desktop/localhost. app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], // index.html carries one inline