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 ? `` : ""; const jobsMarkup = jobs.length === 0 @@ -294,6 +294,33 @@ function renderAppPage({ userId, summary, jobs, flash = null }) { `).join("")} `; + const developerActionsMarkup = showDeveloperActions + ? `
+
+
+

Developer Actions

+ +
+ +
+ + +
+
+ +
+ +
+ + + + +
+
+
+
` + : ""; + return shell({ title: "Dashboard | XArtAudio", user: { authenticated: true, userId }, @@ -325,8 +352,8 @@ function renderAppPage({ userId, summary, jobs, flash = null }) { -
-
+
+

@@ -337,31 +364,7 @@ function renderAppPage({ userId, summary, jobs, flash = null }) { ${jobsMarkup}

- -
-
-
-

Developer Actions

- -
- -
- - -
-
- -
- -
- - - - -
-
-
-
+ ${developerActionsMarkup}
`, }); diff --git a/test/app.test.js b/test/app.test.js index 72b04e8..bff9b8c 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -76,7 +76,7 @@ function createApp(options = {}) { betterAuthBasePath: "/api/auth", xOAuthClientId: "x-client-id", xOAuthClientSecret: "x-client-secret", - internalApiToken: "", + internalApiToken: "internal-token", convexDeploymentUrl: "", convexAuthToken: "", convexStateQuery: "state:getLatestSnapshot", @@ -381,6 +381,7 @@ test("/api/x/mentions returns upstream mentions when configured", async () => { const response = await call(app, { method: "GET", path: "/api/x/mentions", + headers: { "x-internal-token": "internal-token" }, query: { sinceId: "100" }, }); @@ -390,6 +391,61 @@ test("/api/x/mentions returns upstream mentions when configured", async () => { assert.equal(body.mentions[0].id, "m1"); }); +test("/api/x/mentions requires internal token", async () => { + const app = createApp({ + xAdapter: { + isConfigured() { + return true; + }, + async listMentions() { + return []; + }, + }, + }); + + const response = await call(app, { + method: "GET", + path: "/api/x/mentions", + query: { sinceId: "1" }, + }); + + assert.equal(response.status, 401); +}); + +test("cross-site browser posts are blocked", async () => { + const app = createApp(); + const response = await call(app, { + method: "POST", + path: "/app/actions/topup", + headers: { + cookie: "xartaudio_user=alice", + origin: "https://evil.example", + "sec-fetch-site": "cross-site", + }, + body: "amount=5", + }); + + assert.equal(response.status, 403); + assert.match(response.body, /csrf_blocked|invalid_origin/); +}); + +test("dev dashboard routes can be disabled", async () => { + const app = createApp({ + config: { + enableDevRoutes: false, + }, + }); + + const response = await call(app, { + method: "POST", + path: "/app/actions/topup", + headers: { cookie: "xartaudio_user=alice" }, + body: "amount=5", + }); + + assert.equal(response.status, 404); +}); + test("simulate mention schedules background audio generation when service is configured", async () => { const queued = []; const app = createApp({ @@ -573,7 +629,11 @@ test("internal retention endpoint prunes stale content and assets", async () => }); test("internal endpoints are disabled when no token configured", async () => { - const app = createApp(); + const app = createApp({ + config: { + internalApiToken: "", + }, + }); const response = await call(app, { method: "POST", path: "/internal/retention/run", diff --git a/test/config.test.js b/test/config.test.js index b0b8828..52dbbc9 100644 --- a/test/config.test.js +++ b/test/config.test.js @@ -59,6 +59,7 @@ test("config uses defaults when env is missing", () => { assert.equal(config.minioUseSSL, true); assert.equal(config.rateLimits.webhookPerMinute, 120); assert.equal(config.allowInMemoryStateFallback, true); + assert.equal(config.enableDevRoutes, true); assert.equal(config.abuse.maxJobsPerUserPerDay, 0); assert.equal(config.abuse.cooldownSec, 0); assert.deepEqual(config.abuse.denyUserIds, []); @@ -117,6 +118,7 @@ test("config reads convex/qwen/minio overrides", () => { assert.equal(config.abuse.cooldownSec, 120); assert.deepEqual(config.abuse.denyUserIds, ["u1", "u2"]); assert.equal(config.allowInMemoryStateFallback, false); + assert.equal(config.enableDevRoutes, false); }); }); diff --git a/test/views.test.js b/test/views.test.js index fc57385..325e65a 100644 --- a/test/views.test.js +++ b/test/views.test.js @@ -45,6 +45,18 @@ test("app page renders stats and forms", () => { assert.match(html, /Hello/); }); +test("app page can hide developer actions", () => { + const html = renderAppPage({ + userId: "u1", + summary: { balance: 4, totalJobs: 2, totalCreditsSpent: 2 }, + jobs: [], + showDeveloperActions: false, + }); + + assert.doesNotMatch(html, /Developer Actions/); + assert.doesNotMatch(html, /\/app\/actions\/topup/); +}); + test("audio page shows unlock action when payment is required", () => { const html = renderAudioPage({ audio: { id: "1", storageKey: "audio/1.mp3", articleTitle: "A", durationSec: 30 },