diff --git a/apps/pwa/src/lib/html.mjs b/apps/pwa/src/lib/html.mjs index 85342d8..18508b7 100644 --- a/apps/pwa/src/lib/html.mjs +++ b/apps/pwa/src/lib/html.mjs @@ -101,14 +101,22 @@ ${head} `; } -export function appBar(user) { +// Takes the request, not just the user: signing out is a POST, and csrfGuard +// rejects any POST whose _csrf does not match the cookie. Without the hidden +// field here every "Sign out" click answered "bad csrf token". +// +// The field is written out rather than imported from session.mjs on purpose -- +// this module is the view layer and has no imports, and pulling in session.mjs +// would drag the database driver along with it. +export function appBar(req) { + const user = req?.user; return `
LSLogicSRCcredentials
${user ? `${esc(user.email || user.display_name || "signed in")} Settings -
` +
` : `Sign in`}
`; diff --git a/apps/pwa/src/routes/cli.mjs b/apps/pwa/src/routes/cli.mjs index f96e075..5558b95 100644 --- a/apps/pwa/src/routes/cli.mjs +++ b/apps/pwa/src/routes/cli.mjs @@ -36,7 +36,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 || "logicsrc cli").slice(0, 40); - const body = `${appBar(req.user)} + const body = `${appBar(req)}
🔑
@@ -142,7 +142,7 @@ cliRouter.post("/cli/device/code", async (req, res) => { }); const devicePage = (req, body) => - page({ title: "LogicSRC â–¸ authorize CLI", body: `${appBar(req.user)}
${body}
${footer}` }); + page({ title: "LogicSRC â–¸ authorize CLI", body: `${appBar(req)}
${body}
${footer}` }); const deviceResult = (req, res, status, heading, detail) => res.status(status).type("html").send(devicePage(req, `
diff --git a/apps/pwa/src/routes/pages.mjs b/apps/pwa/src/routes/pages.mjs index 8677c28..f6fc3fd 100644 --- a/apps/pwa/src/routes/pages.mjs +++ b/apps/pwa/src/routes/pages.mjs @@ -7,6 +7,7 @@ import { id, token, sha256 } from "../lib/crypto.mjs"; import { page, footer, appBar, esc } from "../lib/html.mjs"; import { requireAuth, csrfInput } from "../lib/session.mjs"; import { createApiKey, listApiKeys, revokeApiKey } from "../lib/apikey.mjs"; +import { requestOrigin } from "../lib/origin.mjs"; import { config } from "../config.mjs"; export const pagesRouter = Router(); @@ -60,10 +61,10 @@ export async function dashboardHandler(req, res) { for (const t of teams) cards += await teamCard(t, uid); cards = cards.split(CSRF).join(csrfInput(req)); - const body = `${appBar(req.user)} + const body = `${appBar(req)}

Your teams

${teams.length}
- ${CLI_HINT(config.origin)} + ${CLI_HINT(requestOrigin(req, config.origin))} ${cards || `
You're not on any teams yet. Create one below or accept an invite.
`}
New team
${csrfInput(req)} @@ -109,7 +110,7 @@ pagesRouter.get("/teams/accept", requireAuth, (req, res) => { const tok = String(req.query.token || ""); const shared = req.query.shared; const err = req.query.err; - const body = `${appBar(req.user)} + const body = `${appBar(req)}

Accept team invite

@@ -143,7 +144,7 @@ pagesRouter.get("/settings", requireAuth, async (req, res) => { ${esc(k.name)} ${esc(k.prefix)}… ${csrfInput(req)}
`).join("") : `
no keys yet
`; - const body = `${appBar(req.user)} + const body = `${appBar(req)}

Settings

${newKey ? `
New API key (copy it now — shown once):
${esc(newKey)}
` : ""} diff --git a/apps/pwa/test/appbar.test.mjs b/apps/pwa/test/appbar.test.mjs new file mode 100644 index 0000000..b244b89 --- /dev/null +++ b/apps/pwa/test/appbar.test.mjs @@ -0,0 +1,39 @@ +// Signing out is a POST and csrfGuard rejects any POST whose _csrf does not +// match the mc_csrf cookie. The Sign out form shipped without that field, so +// every click answered "bad csrf token" and nobody could log out. These pin the +// hidden input in place. +import assert from "node:assert/strict"; +import test from "node:test"; + +import { appBar } from "../src/lib/html.mjs"; + +const req = (extra = {}) => ({ csrfToken: "deadbeefdeadbeef", user: { email: "a@example.com" }, ...extra }); + +test("the sign-out form carries the CSRF token", () => { + const html = appBar(req()); + assert.match(html, /action="\/auth\/logout"/); + assert.match(html, //); + // the field has to be inside the form, not merely somewhere on the page + const form = html.slice(html.indexOf('action="/auth/logout"')); + assert.ok( + form.indexOf('name="_csrf"') < form.indexOf(""), + "the _csrf input must be inside the sign-out form", + ); +}); + +test("signed-out visitors get no sign-out form at all", () => { + const html = appBar({ user: null, csrfToken: "x" }); + assert.doesNotMatch(html, /\/auth\/logout/); + assert.match(html, /Sign in/); +}); + +test("survives a request with no CSRF token rather than printing undefined", () => { + const html = appBar(req({ csrfToken: undefined })); + assert.match(html, /name="_csrf" value=""/); +}); + +test("the signed-in identity is escaped", () => { + const html = appBar(req({ user: { email: '' } })); + assert.doesNotMatch(html, /