diff --git a/.env.example b/.env.example index 310120b..07cfc1d 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,8 @@ NODE_ENV=production PORT=3000 LOG_LEVEL=info APP_BASE_URL=https://xartaudio.example.com +ENABLE_DEV_ROUTES=false +ALLOW_IN_MEMORY_STATE_FALLBACK=false # Better Auth BETTER_AUTH_SECRET=replace-me diff --git a/src/app.js b/src/app.js index f59ec10..bcfa74a 100644 --- a/src/app.js +++ b/src/app.js @@ -73,6 +73,9 @@ function buildApp({ }); const rateLimits = config.rateLimits || {}; const abusePolicy = config.abuse || {}; + const devRoutesEnabled = config.enableDevRoutes !== undefined + ? Boolean(config.enableDevRoutes) + : true; const polar = polarAdapter || createPolarAdapter({ accessToken: config.polarAccessToken, server: config.polarServer, @@ -249,6 +252,49 @@ function buildApp({ return null; } + function enforceBrowserCsrf(headers) { + const secFetchSite = headers["sec-fetch-site"]; + if (secFetchSite === "cross-site") { + return json(403, { error: "csrf_blocked" }); + } + + const expectedOrigin = (() => { + try { + return new URL(config.appBaseUrl).origin; + } catch { + return null; + } + })(); + + if (!expectedOrigin) { + return null; + } + + const originHeader = headers.origin || null; + if (originHeader) { + try { + if (new URL(originHeader).origin !== expectedOrigin) { + return json(403, { error: "invalid_origin" }); + } + } catch { + return json(403, { error: "invalid_origin" }); + } + } + + const refererHeader = headers.referer || null; + if (refererHeader) { + try { + if (new URL(refererHeader).origin !== expectedOrigin) { + return json(403, { error: "invalid_referer" }); + } + } catch { + return json(403, { error: "invalid_referer" }); + } + } + + return null; + } + function getAbuseDecision(callerUserId) { if (!callerUserId) { return { allowed: true }; @@ -529,6 +575,10 @@ function buildApp({ } if (method === "POST" && path === "/auth/email/sign-in") { + const csrf = enforceBrowserCsrf(safeHeaders); + if (csrf) { + return csrf; + } const rateLimited = enforceRedirectRateLimit(authLimiter, `auth:${clientAddress}`, "/login"); if (rateLimited) { return rateLimited; @@ -555,6 +605,10 @@ function buildApp({ } if (method === "POST" && path === "/auth/email/sign-up") { + const csrf = enforceBrowserCsrf(safeHeaders); + if (csrf) { + return csrf; + } const rateLimited = enforceRedirectRateLimit(authLimiter, `auth:${clientAddress}`, "/login"); if (rateLimited) { return rateLimited; @@ -581,6 +635,10 @@ function buildApp({ } if (method === "POST" && path === "/auth/x") { + const csrf = enforceBrowserCsrf(safeHeaders); + if (csrf) { + return csrf; + } const rateLimited = enforceRedirectRateLimit(authLimiter, `auth:${clientAddress}`, "/login"); if (rateLimited) { return rateLimited; @@ -611,6 +669,10 @@ function buildApp({ } if (method === "POST" && path === "/auth/logout") { + const csrf = enforceBrowserCsrf(safeHeaders); + if (csrf) { + return csrf; + } const signOut = await auth.signOut(safeHeaders); return redirect("/", signOut.setCookie ? { "set-cookie": signOut.setCookie } @@ -628,10 +690,18 @@ function buildApp({ summary: engine.getUserSummary(userId), jobs: engine.listJobsForUser(userId), flash: safeQuery.flash || null, + showDeveloperActions: devRoutesEnabled, })); } if (method === "POST" && path === "/app/actions/topup") { + if (!devRoutesEnabled) { + return json(404, { error: "not_found" }); + } + const csrf = enforceBrowserCsrf(safeHeaders); + if (csrf) { + return csrf; + } const rateLimited = enforceRedirectRateLimit(actionLimiter, `action:${userId || clientAddress}`, "/app"); if (rateLimited) { return rateLimited; @@ -656,6 +726,13 @@ function buildApp({ } if (method === "POST" && path === "/app/actions/simulate-mention") { + if (!devRoutesEnabled) { + return json(404, { error: "not_found" }); + } + const csrf = enforceBrowserCsrf(safeHeaders); + if (csrf) { + return csrf; + } const rateLimited = enforceRedirectRateLimit(actionLimiter, `action:${userId || clientAddress}`, "/app"); if (rateLimited) { return rateLimited; @@ -718,6 +795,10 @@ function buildApp({ } if (method === "POST" && path.startsWith("/audio/") && path.endsWith("/unlock")) { + const csrf = enforceBrowserCsrf(safeHeaders); + if (csrf) { + return csrf; + } const rateLimited = enforceRedirectRateLimit(actionLimiter, `action:${userId || clientAddress}`, path.replace("/unlock", "")); if (rateLimited) { return rateLimited; @@ -803,6 +884,11 @@ function buildApp({ } if (method === "GET" && path === "/api/x/mentions") { + const authResponse = ensureInternalAuth(safeHeaders); + if (authResponse) { + return authResponse; + } + if (!x.isConfigured()) { return json(503, { error: "x_api_not_configured" }); } diff --git a/src/config.js b/src/config.js index f9d35d4..d2d8b0b 100644 --- a/src/config.js +++ b/src/config.js @@ -105,6 +105,10 @@ parsed.allowInMemoryStateFallback = boolFromEnv( "ALLOW_IN_MEMORY_STATE_FALLBACK", parsed.nodeEnv !== "production", ); +parsed.enableDevRoutes = boolFromEnv( + "ENABLE_DEV_ROUTES", + parsed.nodeEnv !== "production", +); const ConfigSchema = z.object({ nodeEnv: z.string().min(1), @@ -158,6 +162,7 @@ const ConfigSchema = z.object({ maxCharsPerArticle: z.number().int().positive(), }), allowInMemoryStateFallback: z.boolean(), + enableDevRoutes: z.boolean(), }); const config = ConfigSchema.parse(parsed); diff --git a/src/views/pages.js b/src/views/pages.js index 2fac579..81c9ac6 100644 --- a/src/views/pages.js +++ b/src/views/pages.js @@ -270,7 +270,7 @@ function renderLoginPage({ returnTo = "/app", error = null }) { }); } -function renderAppPage({ userId, summary, jobs, flash = null }) { +function renderAppPage({ userId, summary, jobs, flash = null, showDeveloperActions = true }) { const flashMarkup = flash ? `