From 285e6c88d5f76992cea9134ca5ecd8faad4f0f16 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 26 Oct 2026 18:38:29 +0000 Subject: [PATCH] footer 0.4.0: inline newsletter feedback; nonce + pit.world in CSP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps @profullstack/footer to ^0.4.0 (lockfile 0.4.0). 0.4.0 posts the footer newsletter form as JSON to https://pit.world/api/subscribe from a small inline script and shows "Email confirmation sent" or "Failed: " inline. CSP changes so that actually runs here: - script-src had no 'unsafe-inline', so / and /docs now mint a per-response nonce, add 'nonce-…' to script-src and pass it to footerHtml({ nonce }). - connect-src adds https://pit.world (the JSON POST). - form-action adds https://pit.world (the no-script fallback POST, which 'self' silently dropped before). tsc --noEmit and test/web.test.ts pass locally. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- src/web.ts | 35 +++++++++++++++++++++++++---------- 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index 1280a0b..5298d32 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "@hono/node-server": "2.1.5", "@libsql/client": "0.18.0", "@modelcontextprotocol/sdk": "1.32.2", - "@profullstack/footer": "^0.2.6", + "@profullstack/footer": "^0.4.0", "@profullstack/profile": "0.1.6", "@profullstack/x402-gateway": "^0.13.1", "@simplewebauthn/server": "^14.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3cb5917..8fc0b91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: 1.32.2 version: 1.32.2(zod@4.6.7) '@profullstack/footer': - specifier: ^0.2.6 - version: 0.2.6 + specifier: ^0.4.0 + version: 0.4.0 '@profullstack/profile': specifier: 0.1.6 version: 0.1.6(@libsql/client@0.18.0) @@ -364,8 +364,8 @@ packages: resolution: {integrity: sha512-adpW58ySPQMOUPv8pTKT3RIyCFqfUFt8X05ukPdtkQToXDVr8dMtxuenik1yx8tNFTbpApWsM/Hi5SzlIx48+Q==} engines: {node: '>=20.0.0'} - '@profullstack/footer@0.2.6': - resolution: {integrity: sha512-BKwbDszAc7e0mYIBiPjgr/5WS8WyDt8IW718s/zW/8pCWOIB1BGwSNs6fmImRSo5kqIjQKUVbXp22jZQ5GEipA==} + '@profullstack/footer@0.4.0': + resolution: {integrity: sha512-EJZnMUQmWDi9Gzx6BAjogloeaPFFSSCHtHJuC9dsXiOz8M87hfn6Z+Zx9AMYWk8s6xemPrPlkXKGB3PZugkcEA==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -1620,7 +1620,7 @@ snapshots: tslib: 2.8.1 tsyringe: 4.10.0 - '@profullstack/footer@0.2.6': {} + '@profullstack/footer@0.4.0': {} '@profullstack/keys@0.1.0': {} diff --git a/src/web.ts b/src/web.ts index e47a69e..1ab278b 100644 --- a/src/web.ts +++ b/src/web.ts @@ -1,4 +1,5 @@ import type { AuthenticationResponseJSON, RegistrationResponseJSON } from '@simplewebauthn/server'; +import { randomBytes } from 'node:crypto'; import { readdirSync, readFileSync } from 'node:fs'; import { dirname, extname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -28,8 +29,20 @@ const TYPES: Record = { '.html': 'text/html; charset=utf-8', }; +// pit.world: the footer's newsletter form POSTs to pit.world/api/subscribe (connect-src), +// falling back to a plain form POST to pit.world/subscribe without script (form-action). export const PAGE_CSP = - "default-src 'self'; script-src 'self' https://crawlproof.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://crawlproof.com; font-src 'self'; frame-src https://crawlproof.com; frame-ancestors 'none'; base-uri 'none'; form-action 'self' http://127.0.0.1:* http://localhost:*"; + "default-src 'self'; script-src 'self' https://crawlproof.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://crawlproof.com https://pit.world; font-src 'self'; frame-src https://crawlproof.com; frame-ancestors 'none'; base-uri 'none'; form-action 'self' https://pit.world http://127.0.0.1:* http://localhost:*"; + +/** PAGE_CSP plus a per-response nonce in script-src, for the footer's inline subscribe script. */ +export function pageCsp(nonce?: string): string { + return nonce ? PAGE_CSP.replace("script-src 'self'", `script-src 'self' 'nonce-${nonce}'`) : PAGE_CSP; +} + +/** A fresh CSP nonce for one response. */ +export function newNonce(): string { + return randomBytes(16).toString('base64'); +} export interface WebOptions { mailer: Mailer; @@ -92,8 +105,8 @@ export const SITE_URL = 'https://submitfleet.com/'; * /assets/pfs-footer.css (css: false here), so the footer does not depend on * style-src allowing inline styles. */ -export function siteFooter(links: { label: string; href: string }[]): Promise { - return footerHtml({ site: SITE_URL, links, css: false }); +export function siteFooter(links: { label: string; href: string }[], nonce?: string): Promise { + return footerHtml({ site: SITE_URL, links, css: false, nonce }); } function safeNext(raw: string | undefined): string { @@ -109,8 +122,8 @@ export function mountWeb(app: Hono<{ Variables: { actor: Actor } }>, fleet: Subm const o = origin(c); return { origin: o, rpID: new URL(o).hostname, rpName: 'SubmitFleet' }; }; - const html = async (c: Context, body: string, status = 200) => { - c.header('content-security-policy', PAGE_CSP); + const html = async (c: Context, body: string, status = 200, nonce?: string) => { + c.header('content-security-policy', pageCsp(nonce)); c.header('cache-control', 'no-store'); return c.html(await withPitFooter(body), status as 200); }; @@ -153,23 +166,25 @@ export function mountWeb(app: Hono<{ Variables: { actor: Actor } }>, fleet: Subm // ── Pages ── app.get('/', async (c) => { const stats = await fleet.catalogStats(); + const nonce = newNonce(); const body = page('index.html') - .replace('{{FOOTER}}', await siteFooter([{ label: 'Docs', href: '/docs' }, { label: 'GitHub', href: 'https://github.com/profullstack/submitfleet' }, { label: 'Sign in', href: '/login' }])) + .replace('{{FOOTER}}', await siteFooter([{ label: 'Docs', href: '/docs' }, { label: 'GitHub', href: 'https://github.com/profullstack/submitfleet' }, { label: 'Sign in', href: '/login' }], nonce)) .replaceAll('{{TOTAL}}', String(stats.total)) .replaceAll('{{FREE}}', String(stats.free)) .replaceAll('{{DIRECTORIES}}', String((stats.by_channel.directory ?? 0) + (stats.by_channel.citation ?? 0))) .replaceAll('{{LAUNCH}}', String(stats.by_channel['product-launch-site'] ?? 0)) .replaceAll('{{AUTOMATED}}', String(stats.automated)); - return html(c, body); + return html(c, body, 200, nonce); }); // Sign-up asks for the profile fields (the form has its own email box); sign-in never does. const signupFields = renderProfileFields({ fields: ['first_name', 'last_name', 'company_domain', 'job_title', 'linkedin_url'] }); app.get('/signup', (c) => html(c, page('auth.html').replace('{{PROFILE_FIELDS}}', signupFields))); app.get('/login', (c) => html(c, page('auth.html').replace('{{PROFILE_FIELDS}}', ''))); app.get('/auth/link', (c) => html(c, page('link.html'))); - app.get('/docs', async (c) => - html(c, page('docs.html').replace('{{FOOTER}}', await siteFooter([{ label: 'Home', href: '/' }, { label: 'GitHub', href: 'https://github.com/profullstack/submitfleet' }]))), - ); + app.get('/docs', async (c) => { + const nonce = newNonce(); + return html(c, page('docs.html').replace('{{FOOTER}}', await siteFooter([{ label: 'Home', href: '/' }, { label: 'GitHub', href: 'https://github.com/profullstack/submitfleet' }], nonce)), 200, nonce); + }); app.get('/.well-known/openwebring.json', () => descriptorResponse({ site: SITE_URL, name: 'SubmitFleet' })); const appShell = async (c: Context) => { const user = await auth.sessionUser(fleet.db, getCookie(c, auth.SESSION_COOKIE));