Make the admin's buttons work in a browser
Every mutating form now carries the CSRF token as a hidden field, and the upload route accepts it as a multipart field as well as a header. Driving the real UI in Firefox found what the header-only tests could not: Create Draft, Save, Publish, Delete and the up/down buttons all posted without a token and were rejected. Adds npm run check-site, which verifies a running Site from the outside and exits non-zero so it can gate a cutover.
This commit is contained in:
1 parent
f89bd63685
commit
af083415a7
6 files changed
+299
-4
No files matched your search
@@ -90,6 +90,7 @@ export interface DashboardInput {
|
||||
config: SiteConfig;
|
||||
sets: readonly StoredSet[];
|
||||
current: ReadonlyMap<string, StoredSet>;
|
||||
csrf: string;
|
||||
notice: string | null;
|
||||
}
|
||||
|
||||
@@ -120,6 +121,7 @@ export function renderDashboard(input: DashboardInput): Html {
|
||||
</ul>
|
||||
</details>`}
|
||||
<form class="newset" method="post" action="/admin/sets">
|
||||
<input type="hidden" name="csrf" value="${input.csrf}" />
|
||||
<input type="hidden" name="section" value="${section.slug}" />
|
||||
${labelled(
|
||||
'New Menu Set',
|
||||
@@ -140,6 +142,7 @@ export function renderDashboard(input: DashboardInput): Html {
|
||||
<header class="admin__bar">
|
||||
<h1 class="admin__title">Menus</h1>
|
||||
<form method="post" action="/admin/logout">
|
||||
<input type="hidden" name="csrf" value="${input.csrf}" />
|
||||
<button class="button" type="submit">Sign out</button>
|
||||
</form>
|
||||
</header>
|
||||
@@ -175,16 +178,19 @@ export function renderEditor(input: EditorInput): Html {
|
||||
${draft
|
||||
? html`<span class="page-admin__actions">
|
||||
<form method="post" action="/admin/sets/${set.id}/move">
|
||||
<input type="hidden" name="csrf" value="${input.csrf}" />
|
||||
<input type="hidden" name="pageId" value="${page.id}" />
|
||||
<input type="hidden" name="direction" value="up" />
|
||||
<button class="button button--icon" type="submit" title="Move up">↑</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/sets/${set.id}/move">
|
||||
<input type="hidden" name="csrf" value="${input.csrf}" />
|
||||
<input type="hidden" name="pageId" value="${page.id}" />
|
||||
<input type="hidden" name="direction" value="down" />
|
||||
<button class="button button--icon" type="submit" title="Move down">↓</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/sets/${set.id}/pages/${page.id}/delete">
|
||||
<input type="hidden" name="csrf" value="${input.csrf}" />
|
||||
<button class="button button--icon button--danger" type="submit" title="Remove page">✕</button>
|
||||
</form>
|
||||
</span>`
|
||||
@@ -217,6 +223,7 @@ export function renderEditor(input: EditorInput): Html {
|
||||
<h2 class="block__title">Details</h2>
|
||||
${draft
|
||||
? html`<form class="inline-form" method="post" action="/admin/sets/${set.id}/update">
|
||||
<input type="hidden" name="csrf" value="${input.csrf}" />
|
||||
${labelled('Label', html`<input type="text" name="label" value="${set.manifest.label}" required />`)}
|
||||
${labelled(
|
||||
'Valid until (optional)',
|
||||
@@ -239,6 +246,7 @@ export function renderEditor(input: EditorInput): Html {
|
||||
<p class="drop__status" data-upload-status role="status"></p>
|
||||
</div>
|
||||
<form method="post" action="/admin/sets/${set.id}/pages" enctype="multipart/form-data" class="no-js-upload">
|
||||
<input type="hidden" name="csrf" value="${input.csrf}" />
|
||||
<input type="file" name="pages" accept="image/*" multiple />
|
||||
<button class="button" type="submit">Upload</button>
|
||||
</form>
|
||||
@@ -256,6 +264,7 @@ export function renderEditor(input: EditorInput): Html {
|
||||
<div class="actions">
|
||||
${draft
|
||||
? html`<form method="post" action="/admin/sets/${set.id}/publish">
|
||||
<input type="hidden" name="csrf" value="${input.csrf}" />
|
||||
<button class="button button--primary" type="submit" ${set.manifest.pages.length === 0 ? 'disabled' : ''}>
|
||||
Publish (${pageCountLabel(set.manifest.pages.length)})
|
||||
</button>
|
||||
@@ -264,10 +273,12 @@ export function renderEditor(input: EditorInput): Html {
|
||||
: html`<a class="button" href="/${section.slug}/${set.slug}/">View live</a>`}
|
||||
${set.state === 'published'
|
||||
? html`<form method="post" action="/admin/sets/${set.id}/restore">
|
||||
<input type="hidden" name="csrf" value="${input.csrf}" />
|
||||
<button class="button" type="submit">Make current</button>
|
||||
</form>`
|
||||
: ''}
|
||||
<form method="post" action="/admin/sets/${set.id}/delete" data-confirm="Delete this Menu Set and its Originals? This cannot be undone.">
|
||||
<input type="hidden" name="csrf" value="${input.csrf}" />
|
||||
<input type="hidden" name="confirm" value="yes" />
|
||||
<button class="button button--danger" type="submit">Delete</button>
|
||||
</form>
|
||||
|
||||
+19
-4
@@ -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
|
||||
|
||||
Reference in new issue
Block a user