Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions apps/pwa/src/lib/html.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -101,14 +101,22 @@ ${head}
</html>`;
}

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 `<header class="bar"><div class="wrap bar-inner">
<a class="brand" href="/"><span class="mark">LS</span>LogicSRC<span class="app">credentials</span></a>
<div class="bar-right">
${user
? `<span class="mono faint" style="font-size:.78rem">${esc(user.email || user.display_name || "signed in")}</span>
<a class="btn" href="/settings">Settings</a>
<form method="post" action="/auth/logout" style="margin:0"><button class="btn">Sign out</button></form>`
<form method="post" action="/auth/logout" style="margin:0"><input type="hidden" name="_csrf" value="${esc(req?.csrfToken)}"><button class="btn">Sign out</button></form>`
: `<a class="btn acid" href="/">Sign in</a>`}
</div>
</div></header>`;
Expand Down
4 changes: 2 additions & 2 deletions apps/pwa/src/routes/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ cliRouter.get("/cli/authorize", requireAuth, (req, res) => {
return res.status(400).type("html").send(page({ body: `<main class="wrap" style="padding-top:12vh"><h1>Bad CLI request</h1><p class="dim mono">missing/invalid redirect_uri, state, or code_challenge.</p></main>` }));
}
const name = String(req.query.name || "logicsrc cli").slice(0, 40);
const body = `${appBar(req.user)}
const body = `${appBar(req)}
<main class="wrap" style="max-width:460px;padding-top:8vh">
<div class="card"><div class="card-body" style="text-align:center">
<div style="font-size:2rem">🔑</div>
Expand Down Expand Up @@ -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)}<main class="wrap" style="max-width:460px;padding-top:8vh">${body}</main>${footer}` });
page({ title: "LogicSRC ▸ authorize CLI", body: `${appBar(req)}<main class="wrap" style="max-width:460px;padding-top:8vh">${body}</main>${footer}` });

const deviceResult = (req, res, status, heading, detail) =>
res.status(status).type("html").send(devicePage(req, `<div class="card"><div class="card-body" style="text-align:center">
Expand Down
9 changes: 5 additions & 4 deletions apps/pwa/src/routes/pages.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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)}
<main class="wrap" style="max-width:820px;padding:26px 0 40px">
<div class="section-title"><h1 style="font-size:1.6rem">Your teams</h1><span class="count">${teams.length}</span></div>
${CLI_HINT(config.origin)}
${CLI_HINT(requestOrigin(req, config.origin))}
${cards || `<div class="card"><div class="card-body dim">You're not on any teams yet. Create one below or accept an invite.</div></div>`}
<div class="card" style="margin-top:22px"><div class="card-head"><span class="h">New team</span></div>
<div class="card-body"><form method="post" action="/teams" style="display:flex;gap:8px">${csrfInput(req)}
Expand Down Expand Up @@ -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)}
<main class="wrap" style="max-width:460px;padding-top:8vh">
<div class="card"><div class="card-body" style="text-align:center">
<h1 style="font-size:1.4rem;margin-bottom:12px">Accept team invite</h1>
Expand Down Expand Up @@ -143,7 +144,7 @@ pagesRouter.get("/settings", requireAuth, async (req, res) => {
<span style="flex:1">${esc(k.name)} <span class="faint">${esc(k.prefix)}…</span></span>
<form method="post" action="/settings/apikeys/${k.id}/delete" style="margin:0">${csrfInput(req)}<button class="btn danger" style="padding:5px 10px;font-size:.72rem">revoke</button></form>
</div>`).join("") : `<div class="faint mono" style="font-size:.78rem;padding:6px 0">no keys yet</div>`;
const body = `${appBar(req.user)}
const body = `${appBar(req)}
<main class="wrap" style="max-width:640px;padding-top:30px">
<h1 style="font-size:1.5rem;margin-bottom:20px">Settings</h1>
${newKey ? `<div class="notice ok">New API key (copy it now — shown once):<br><b class="mono" style="word-break:break-all">${esc(newKey)}</b></div>` : ""}
Expand Down
39 changes: 39 additions & 0 deletions apps/pwa/test/appbar.test.mjs
Original file line number Diff line number Diff line change
@@ -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, /<input type="hidden" name="_csrf" value="deadbeefdeadbeef">/);
// 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("</form>"),
"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: '<script>alert(1)</script>' } }));
assert.doesNotMatch(html, /<script>alert/);
assert.match(html, /&lt;script&gt;/);
});
Loading