harden browser routes with csrf checks and lock internal/dev endpoints

This commit is contained in:
Codex
2026-02-18 15:27:47 +00:00
parent 4814342156
commit f672677d4f
7 changed files with 200 additions and 30 deletions

View File

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

View File

@@ -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" });
}

View File

@@ -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);

View File

@@ -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 ? `<div role="alert" class="alert alert-info mb-6 flex items-center gap-2 text-sm shadow-sm"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-5 h-5"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg><span>${escapeHtml(flash)}</span></div>` : "";
const jobsMarkup = jobs.length === 0
@@ -294,6 +294,33 @@ function renderAppPage({ userId, summary, jobs, flash = null }) {
</a>`).join("")}
</div>`;
const developerActionsMarkup = showDeveloperActions
? `<div class="space-y-6">
<div class="card bg-base-100 shadow-sm border border-base-content/10 sticky top-24">
<div class="card-body p-5">
<h2 class="card-title text-sm font-bold uppercase tracking-wider opacity-60 mb-2">Developer Actions</h2>
<form method="POST" action="/app/actions/topup" class="mb-4">
<label class="label text-xs font-medium pl-0">Top Up Credits</label>
<div class="join w-full">
<input name="amount" type="number" min="1" max="500" value="10" class="input input-bordered input-sm join-item w-full" />
<button class="btn btn-primary btn-sm join-item">Add</button>
</div>
</form>
<div class="divider my-2"></div>
<form method="POST" action="/app/actions/simulate-mention" class="flex flex-col gap-3">
<label class="label text-xs font-medium pl-0">Simulate Mention</label>
<input name="title" required class="input input-bordered input-sm w-full" placeholder="Article Title" />
<textarea name="body" required rows="3" class="textarea textarea-bordered textarea-sm w-full" placeholder="Paste article text..."></textarea>
<button class="btn btn-accent btn-sm w-full">Generate</button>
</form>
</div>
</div>
</div>`
: "";
return shell({
title: "Dashboard | XArtAudio",
user: { authenticated: true, userId },
@@ -325,8 +352,8 @@ function renderAppPage({ userId, summary, jobs, flash = null }) {
</div>
</div>
<div class="grid gap-8 md:grid-cols-3 mb-12">
<div class="md:col-span-2 space-y-8">
<div class="grid gap-8 ${showDeveloperActions ? "md:grid-cols-3" : ""} mb-12">
<div class="${showDeveloperActions ? "md:col-span-2" : ""} space-y-8">
<section>
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold flex items-center gap-2">
@@ -337,31 +364,7 @@ function renderAppPage({ userId, summary, jobs, flash = null }) {
${jobsMarkup}
</section>
</div>
<div class="space-y-6">
<div class="card bg-base-100 shadow-sm border border-base-content/10 sticky top-24">
<div class="card-body p-5">
<h2 class="card-title text-sm font-bold uppercase tracking-wider opacity-60 mb-2">Developer Actions</h2>
<form method="POST" action="/app/actions/topup" class="mb-4">
<label class="label text-xs font-medium pl-0">Top Up Credits</label>
<div class="join w-full">
<input name="amount" type="number" min="1" max="500" value="10" class="input input-bordered input-sm join-item w-full" />
<button class="btn btn-primary btn-sm join-item">Add</button>
</div>
</form>
<div class="divider my-2"></div>
<form method="POST" action="/app/actions/simulate-mention" class="flex flex-col gap-3">
<label class="label text-xs font-medium pl-0">Simulate Mention</label>
<input name="title" required class="input input-bordered input-sm w-full" placeholder="Article Title" />
<textarea name="body" required rows="3" class="textarea textarea-bordered textarea-sm w-full" placeholder="Paste article text..."></textarea>
<button class="btn btn-accent btn-sm w-full">Generate</button>
</form>
</div>
</div>
</div>
${developerActionsMarkup}
</div>
`,
});

View File

@@ -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",

View File

@@ -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);
});
});

View File

@@ -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 },