diff --git a/README.md b/README.md index 00ba007..9158c8b 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,13 @@ which needs a human present. then the playbook in `ansible-playbooks/menu-admin/` — its readme covers the parallel run, the `site.env` step that gives the admin a password, and cutover. +After deploying, check the running site from the outside (read-only, exits +non-zero on failure, so it can gate a cutover): + +```sh +npm run check-site -- https://menu-new.hopsngrains.com +``` + ## Layout ``` diff --git a/package.json b/package.json index ffc2056..1cd1312 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "start": "node src/server.ts", "dev": "SECURE_COOKIES=false node --watch src/server.ts", "seed": "node scripts/seed-dev.ts", + "check-site": "node scripts/check-site.ts", "verify": "node src/cli/verify.ts", "repair": "node src/cli/repair.ts", "typecheck": "tsc --noEmit", diff --git a/scripts/check-site.ts b/scripts/check-site.ts new file mode 100644 index 0000000..b243f55 --- /dev/null +++ b/scripts/check-site.ts @@ -0,0 +1,125 @@ +/** + * Checks a running Site from the outside, the way a customer and a phone would + * meet it. Read-only: it never signs in and never writes. + * + * npm run check-site -- https://menu-new.hopsngrains.com + * + * Exits non-zero if anything fails, so it can gate a cutover. + */ + +const base = (process.argv[2] ?? 'http://127.0.0.1:3000').replace(/\/$/, ''); + +interface Result { + name: string; + ok: boolean; + detail: string; +} + +const results: Result[] = []; + +function record(name: string, ok: boolean, detail: string): void { + results.push({ name, ok, detail }); +} + +async function request(path: string): Promise { + return fetch(`${base}${path}`, { redirect: 'manual' }); +} + +async function checkPage(path: string, expected: string[]): Promise { + let response: Response; + try { + response = await request(path); + } catch (error) { + record(`GET ${path}`, false, `unreachable: ${(error as Error).message}`); + return null; + } + const body = await response.text(); + const missing = expected.filter((needle) => !body.includes(needle)); + record( + `GET ${path}`, + response.status === 200 && missing.length === 0, + `status ${response.status}${missing.length === 0 ? '' : `, missing ${missing.join(', ')}`}`, + ); + return body; +} + +// --- The site answers at all ------------------------------------------------- + +const landing = await checkPage('/', [' m[1]!); + +if (sections.length === 0) { + record('at least one Section has a Current Menu Set', false, 'the landing page lists none'); +} + +for (const href of sections) { + const body = await checkPage(href, ['class="page"']); + if (body === null) continue; + + const pages = [...body.matchAll(/data-page="(\d+)"/g)].map((match) => match[1]!); + record(`${href} shows Pages`, pages.length > 0, `${pages.length} page(s)`); + + const media = body.match(/\/media\/[0-9a-f-]{36}\.[0-9a-f]+\.webp/); + if (media === null) { + record(`${href} links a Viewing image`, false, 'no media URL in the page'); + continue; + } + + const image = await request(media[0]); + const cache = image.headers.get('cache-control') ?? ''; + record( + `GET ${media[0]}`, + image.status === 200 && + (image.headers.get('content-type') ?? '').startsWith('image/webp') && + cache.includes('immutable'), + `status ${image.status}, type ${image.headers.get('content-type')}, cache ${cache}`, + ); + + const jpeg = await request(media[0].replace(/\.webp$/, '.jpg')); + record( + `GET ${media[0].replace(/\.webp$/, '.jpg')}`, + jpeg.status === 200 && (jpeg.headers.get('content-type') ?? '').startsWith('image/jpeg'), + `status ${jpeg.status}, type ${jpeg.headers.get('content-type')}`, + ); + + const wrongExtension = await request(media[0].replace(/\.webp$/, '.jpeg')); + record( + 'Originals are not reachable', + wrongExtension.status === 404, + `a non-variant extension gives ${wrongExtension.status}`, + ); + + const traversal = await request(`${media[0].replace('/media/', '/media/..%2Foriginals%2F')}`); + record( + 'media paths cannot escape the set directory', + traversal.status === 404, + `status ${traversal.status}`, + ); +} + +// --- Report ----------------------------------------------------------------- + +const width = Math.max(...results.map((result) => result.name.length)); +for (const result of results) { + console.log(`${result.ok ? 'ok ' : 'FAIL'} ${result.name.padEnd(width)} ${result.detail}`); +} + +const failures = results.filter((result) => !result.ok); +console.log(`\n${results.length - failures.length}/${results.length} checks passed against ${base}`); +if (failures.length > 0) process.exitCode = 1; diff --git a/src/render/admin.ts b/src/render/admin.ts index d23e67c..62cc7f8 100644 --- a/src/render/admin.ts +++ b/src/render/admin.ts @@ -90,6 +90,7 @@ export interface DashboardInput { config: SiteConfig; sets: readonly StoredSet[]; current: ReadonlyMap; + csrf: string; notice: string | null; } @@ -120,6 +121,7 @@ export function renderDashboard(input: DashboardInput): Html { `}
+ ${labelled( 'New Menu Set', @@ -140,6 +142,7 @@ export function renderDashboard(input: DashboardInput): Html {

Menus

+
@@ -175,16 +178,19 @@ export function renderEditor(input: EditorInput): Html { ${draft ? html`
+
+
+
` @@ -217,6 +223,7 @@ export function renderEditor(input: EditorInput): Html {

Details

${draft ? html`
+ ${labelled('Label', html``)} ${labelled( 'Valid until (optional)', @@ -239,6 +246,7 @@ export function renderEditor(input: EditorInput): Html {

+
@@ -256,6 +264,7 @@ export function renderEditor(input: EditorInput): Html {
${draft ? html`
+ @@ -264,10 +273,12 @@ export function renderEditor(input: EditorInput): Html { : html`View live`} ${set.state === 'published' ? html` +
` : ''}
+
diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 60fc338..ad2b03f 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -165,6 +165,7 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute config, sets: await store.list(), current: await store.currentBySection(), + csrf: csrfToken(sessionToken(request), deps.sessionSecret), notice, }).value, ); @@ -288,19 +289,31 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute // ---------- Pages ---------- app.post<{ Params: { id: string } }>('/admin/sets/:id/pages', async (request, reply) => { - if (!authed(request) || !sameOrigin(request) || !csrfOk(request)) { - return reply.code(403).send('Rejected.'); - } + if (!authed(request) || !sameOrigin(request)) return reply.code(403).send('Rejected.'); const set = await store.get(request.params.id); if (set.state !== 'draft') { return reply.code(409).send({ error: 'A published Menu Set cannot be changed.' }); } if (!request.isMultipart()) return reply.code(400).send('Expected a file upload.'); + // The script sends the token as a header; a plain form sends it as a + // multipart field, which request.body never sees. Either is fine, but a + // file is only stored once one of them has been checked. + const token = sessionToken(request); + let authorised = verifyCsrf(token, deps.sessionSecret, request.headers['x-csrf']); const added: string[] = []; const failed: { file: string; error: string }[] = []; for await (const part of request.parts()) { - if (part.type !== 'file') continue; + if (part.type === 'field') { + if (part.fieldname === 'csrf') { + authorised = authorised || verifyCsrf(token, deps.sessionSecret, part.value); + } + continue; + } + if (!authorised) { + await part.toBuffer().catch(() => undefined); + continue; + } const filename = part.filename || 'upload'; try { const data = await part.toBuffer(); @@ -319,6 +332,8 @@ export async function registerAdminRoutes(app: FastifyInstance, deps: AdminRoute } } + if (!authorised) return reply.code(403).send('Rejected.'); + if (wantsJson(request)) return reply.send({ added: added.length, failed }); const notice = failed.length === 0 diff --git a/test/admin.test.ts b/test/admin.test.ts index d1f96e4..d6d57a7 100644 --- a/test/admin.test.ts +++ b/test/admin.test.ts @@ -491,6 +491,142 @@ describe('publishing, restoring and deleting', () => { }); }); +describe('forms as a browser sends them', () => { + // A real browser posts form fields, not an x-csrf header. These tests exist + // because the header path passed while every button in the UI was broken. + function csrfFrom(body: string): string { + const match = body.match(/name="csrf" value="([^"]+)"/); + assert.ok(match, 'the page carries no CSRF field'); + return match[1]!; + } + + const encoded = (fields: Record) => ({ + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + payload: new URLSearchParams(fields).toString(), + }); + + it('creates a Draft from the dashboard form, with no CSRF header', async () => { + const dashboard = await app.inject({ method: 'GET', url: '/admin', cookies: cookies() }); + const response = await app.inject({ + method: 'POST', + url: '/admin/sets', + cookies: cookies(), + ...encoded({ csrf: csrfFrom(dashboard.body), section: 'bar', label: 'Bar Menu' }), + }); + assert.equal(response.statusCode, 303); + assert.match(String(response.headers['location']), /^\/admin\/sets\//); + }); + + it('publishes from the editor form, with no CSRF header', async () => { + const { id } = await newSet(); + await upload(id, 'one.jpg'); + const editor = await app.inject({ method: 'GET', url: `/admin/sets/${id}`, cookies: cookies() }); + const response = await app.inject({ + method: 'POST', + url: `/admin/sets/${id}/publish`, + cookies: cookies(), + ...encoded({ csrf: csrfFrom(editor.body) }), + }); + assert.equal(response.statusCode, 303); + assert.equal(response.headers['location'], '/bar/'); + }); + + it('moves a Page with the up button as a browser sends it', async () => { + const { id } = await newSet(); + await upload(id, 'one.jpg'); + await upload(id, 'two.jpg'); + const before = (await app.menu.store.get(id)).manifest.pages.map((page) => page.id); + const editor = await app.inject({ method: 'GET', url: `/admin/sets/${id}`, cookies: cookies() }); + const response = await app.inject({ + method: 'POST', + url: `/admin/sets/${id}/move`, + cookies: cookies(), + headers: { + 'content-type': 'application/x-www-form-urlencoded', + referer: `/admin/sets/${id}`, + }, + payload: new URLSearchParams({ + csrf: csrfFrom(editor.body), + pageId: before[1]!, + direction: 'up', + }).toString(), + }); + assert.equal(response.statusCode, 303); + assert.deepEqual( + (await app.menu.store.get(id)).manifest.pages.map((page) => page.id), + [before[1], before[0]], + ); + }); + + it('accepts the token as a multipart field, the way the no-JS upload form sends it', async () => { + const { id } = await newSet(); + const editor = await app.inject({ method: 'GET', url: `/admin/sets/${id}`, cookies: cookies() }); + const token = csrfFrom(editor.body); + + const boundary = '----formboundary'; + const payload = Buffer.concat([ + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="csrf"\r\n\r\n${token}\r\n`, + ), + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="pages"; filename="one.jpg"\r\nContent-Type: image/jpeg\r\n\r\n`, + ), + pngBytes('one'), + Buffer.from(`\r\n--${boundary}--\r\n`), + ]); + + const response = await app.inject({ + method: 'POST', + url: `/admin/sets/${id}/pages`, + cookies: cookies(), + headers: { + 'content-type': `multipart/form-data; boundary=${boundary}`, + referer: `/admin/sets/${id}`, + }, + payload, + }); + assert.equal(response.statusCode, 303); + assert.equal((await app.menu.store.get(id)).manifest.pages.length, 1); + }); + + it('refuses a multipart upload that carries no token at all', async () => { + const { id } = await newSet(); + const boundary = '----formboundary'; + const payload = Buffer.concat([ + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="pages"; filename="one.jpg"\r\nContent-Type: image/jpeg\r\n\r\n`, + ), + pngBytes('one'), + Buffer.from(`\r\n--${boundary}--\r\n`), + ]); + + const response = await app.inject({ + method: 'POST', + url: `/admin/sets/${id}/pages`, + cookies: cookies(), + headers: { 'content-type': `multipart/form-data; boundary=${boundary}` }, + payload, + }); + assert.equal(response.statusCode, 403); + assert.equal((await app.menu.store.get(id)).manifest.pages.length, 0); + }); + + it('puts a CSRF field in every form that mutates', async () => { + const { id } = await newSet('bar', 'Bar Menu'); + await upload(id, 'one.jpg'); + const dashboard = (await app.inject({ method: 'GET', url: '/admin', cookies: cookies() })).body; + const editor = (await app.inject({ method: 'GET', url: `/admin/sets/${id}`, cookies: cookies() })).body; + + for (const [where, body] of [['dashboard', dashboard], ['editor', editor]] as const) { + const forms = [...body.matchAll(/]*>[\s\S]*?<\/form>/g)].map((match) => match[0]); + assert.ok(forms.length > 0, `no forms found in the ${where}`); + for (const form of forms) { + assert.match(form, /name="csrf"/, `a ${where} form carries no CSRF field:\n${form}`); + } + } + }); +}); + describe('previewing a Draft', () => { it('renders the customer page with a banner, and serves its media to the session', async () => { const { id } = await newSet('bar', 'Bar Menu', '2026-06-26');