harden browser routes with csrf checks and lock internal/dev endpoints
This commit is contained in:
@@ -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
|
||||
|
||||
86
src/app.js
86
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" });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user