From fe8a8ec7f76f7b14432612468ade090998e952bc Mon Sep 17 00:00:00 2001 From: khipu-agent Date: Sun, 26 Jul 2026 22:21:34 -0600 Subject: [PATCH] fix(pwa): include the CSRF token in the app-bar sign-out form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global csrfGuard rejects any browser POST without a matching _csrf field, and the sign-out form rendered by appBar() didn't carry one โ€” so clicking "Sign out" always returned 403 and the session stayed alive. Thread req.csrfToken into appBar() like every other form already does. --- apps/pwa/src/lib/html.mjs | 4 +- apps/pwa/src/routes/approvals.mjs | 2 +- apps/pwa/src/routes/cli.mjs | 4 +- apps/pwa/src/routes/pages.mjs | 4 +- apps/pwa/test/logout-csrf.test.mjs | 110 +++++++++++++++++++++++++++++ 5 files changed, 117 insertions(+), 7 deletions(-) create mode 100644 apps/pwa/test/logout-csrf.test.mjs diff --git a/apps/pwa/src/lib/html.mjs b/apps/pwa/src/lib/html.mjs index 9a257f7..174dfd0 100644 --- a/apps/pwa/src/lib/html.mjs +++ b/apps/pwa/src/lib/html.mjs @@ -100,13 +100,13 @@ ${head} `; } -export function appBar(user, balance) { +export function appBar(user, balance, csrf = "") { return `
MMOSHCODEapp
${user ? `โ—† ${balance.toLocaleString()} cr Settings -
` +
` : `Sign in`}
`; diff --git a/apps/pwa/src/routes/approvals.mjs b/apps/pwa/src/routes/approvals.mjs index 1d41c81..904951c 100644 --- a/apps/pwa/src/routes/approvals.mjs +++ b/apps/pwa/src/routes/approvals.mjs @@ -101,7 +101,7 @@ approvalsRouter.get("/approve/:id", async (req, res) => {
${esc(k)}
${esc(String(v))}
`).join(""); const t = req.query.t ? `?t=${esc(req.query.t)}` : ""; - const body = `${appBar(req.user, req.user ? await balance(req.user.id) : 0)} + const body = `${appBar(req.user, req.user ? await balance(req.user.id) : 0, req.csrfToken)}
${esc(a.script || "moshscript")} ยท ${a.kind}() diff --git a/apps/pwa/src/routes/cli.mjs b/apps/pwa/src/routes/cli.mjs index ad9fb10..a4c9396 100644 --- a/apps/pwa/src/routes/cli.mjs +++ b/apps/pwa/src/routes/cli.mjs @@ -38,7 +38,7 @@ cliRouter.get("/cli/authorize", requireAuth, (req, res) => { return res.status(400).type("html").send(page({ body: `

Bad CLI request

missing/invalid redirect_uri, state, or code_challenge.

` })); } const name = String(req.query.name || "moshcode cli").slice(0, 40); - const body = `${appBar(req.user, 0)} + const body = `${appBar(req.user, 0, req.csrfToken)}
๐Ÿ”‘
@@ -129,7 +129,7 @@ cliRouter.get("/device", requireAuth, (req, res) => { const prefill = req.query.code ? normCode(req.query.code) : ""; const done = req.query.done; const bad = req.query.bad; - const body = `${appBar(req.user, 0)} + const body = `${appBar(req.user, 0, req.csrfToken)}
๐Ÿ”‘
diff --git a/apps/pwa/src/routes/pages.mjs b/apps/pwa/src/routes/pages.mjs index 9d10989..80100ec 100644 --- a/apps/pwa/src/routes/pages.mjs +++ b/apps/pwa/src/routes/pages.mjs @@ -53,7 +53,7 @@ export async function dashboardHandler(req, res) { ${l.delta >= 0 ? "+" : ""}${l.delta}
`).join("") || `
no activity yet
`; - const body = `${appBar(req.user, bal)} + const body = `${appBar(req.user, bal, req.csrfToken)}
${pending.length} waiting on you ยทbalance ${bal.toLocaleString()} cr @@ -130,7 +130,7 @@ pagesRouter.get("/settings", requireAuth, async (req, res) => {
${csrfInput(req)}
`).join("") : `
no keys yet
`; - const body = `${appBar(req.user, bal)} + const body = `${appBar(req.user, bal, req.csrfToken)}

Settings

${err ? `
${esc(err.replace(/-/g, " "))}
` : ""} diff --git a/apps/pwa/test/logout-csrf.test.mjs b/apps/pwa/test/logout-csrf.test.mjs new file mode 100644 index 0000000..7f16c79 --- /dev/null +++ b/apps/pwa/test/logout-csrf.test.mjs @@ -0,0 +1,110 @@ +// Integration test for signing out from the app bar. +// +// The global csrfGuard requires a matching _csrf field on every browser POST. +// The sign-out form rendered by appBar() must therefore carry the token โ€” +// a form without it always gets a 403 and the user stays logged in. +// +// Boots the real routers against a throwaway libsql file database; skips +// cleanly when the PWA dependencies are not installed. +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import test from "node:test"; + +const require = createRequire(import.meta.url); +let deps = null; +try { + deps = { express: require("express"), cookieParser: require("cookie-parser") }; +} catch { + deps = null; // pwa dependencies not installed โ€” tests below skip +} + +// Point the app at a throwaway database BEFORE importing its modules (config +// reads the environment once, at import time). +const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-pwa-test-")); +process.env.DATABASE_URL = `file:${path.join(workdir, "test.db")}`; +process.env.SESSION_SECRET = "test-secret"; + +const CSRF = "test-csrf-token"; +const SESSION = "test-session-token"; + +async function boot() { + const { migrate } = await import("../src/migrate.mjs"); + await migrate(); + const { run, get, db } = await import("../src/db.mjs"); + const { sessionMiddleware, csrfGuard } = await import("../src/lib/session.mjs"); + const { authRouter } = await import("../src/routes/auth.mjs"); + const { pagesRouter } = await import("../src/routes/pages.mjs"); + + const app = deps.express(); + app.use(deps.express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString("utf8"); } })); + app.use(deps.express.urlencoded({ extended: false })); + app.use(deps.cookieParser()); + app.use(sessionMiddleware); + app.use(csrfGuard); + app.use(authRouter); + app.use(pagesRouter); + const server = await new Promise((resolve) => { + const s = app.listen(0, "127.0.0.1", () => resolve(s)); + }); + const base = `http://127.0.0.1:${server.address().port}`; + const cookies = `mc_sess=${SESSION}; mc_csrf=${CSRF}`; + + await run(`INSERT INTO users (id, email, display_name, created_at) VALUES ('u1','a@b.c','demo',1)`); + await run(`INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?,?,?,?)`, + [SESSION, "u1", Date.now(), Date.now() + 60_000]); + + return { run, get, db, server, base, cookies }; +} + +let booted = null; +const app = () => (booted ||= boot()); + +test.after(() => { + if (!booted) return; + booted.then(({ server, db }) => { server.close(); db.close?.(); }) + .finally(() => { try { fs.rmSync(workdir, { recursive: true, force: true }); } catch { /* noop */ } }); +}); + +test("sign-out form carries the CSRF token, and submitting it logs out", { skip: !deps && "apps/pwa deps not installed" }, async () => { + const { get, base, cookies } = await app(); + + const page = await fetch(`${base}/`, { headers: { cookie: cookies } }); + assert.equal(page.status, 200); + const html = await page.text(); + + // The app-bar sign-out form must include the session's CSRF token, or the + // global csrfGuard rejects the POST with 403 and the user can never log out. + const form = /
]*>([\s\S]*?)<\/form>/.exec(html); + assert.ok(form, "dashboard renders a sign-out form"); + const token = /name="_csrf" value="([^"]+)"/.exec(form[1]); + assert.ok(token, "sign-out form must include a hidden _csrf field"); + assert.equal(token[1], CSRF); + + // Submitting the form as rendered destroys the session. + const res = await fetch(`${base}/auth/logout`, { + method: "POST", + headers: { cookie: cookies, "content-type": "application/x-www-form-urlencoded" }, + body: `_csrf=${encodeURIComponent(token[1])}`, + redirect: "manual", + }); + assert.equal(res.status, 302); + assert.equal(await get(`SELECT token FROM sessions WHERE token = ?`, [SESSION]), null, "session row must be deleted"); +}); + +test("csrfGuard still rejects a sign-out POST without a token", { skip: !deps && "apps/pwa deps not installed" }, async () => { + const { run, get, base, cookies } = await app(); + await run(`INSERT OR REPLACE INTO sessions (token, user_id, created_at, expires_at) VALUES (?,?,?,?)`, + [SESSION, "u1", Date.now(), Date.now() + 60_000]); + + const res = await fetch(`${base}/auth/logout`, { + method: "POST", + headers: { cookie: cookies }, + redirect: "manual", + }); + assert.equal(res.status, 403); + assert.ok(await get(`SELECT token FROM sessions WHERE token = ?`, [SESSION]), "session must survive a rejected POST"); +});