From b848fefc4573437d0b750e795a29dd4db688042b Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 26 Oct 2026 18:34:39 +0000 Subject: [PATCH] footer 0.4.0: inline newsletter feedback; CSP allows it Bump @profullstack/footer to 0.4.0 so the newsletter form reports "Email confirmation sent" / "Failed: why" inline. Both CSPs (site and OAuth consent) get the sha256 of the footer's inline wireSubscribe script (no 'unsafe-inline'; the footer is a cached string, so no nonce), and connect-src/form-action allow https://pit.world. Co-Authored-By: Claude Opus 5.5 --- bun.lock | 4 ++-- package.json | 2 +- src/auth-routes.mjs | 4 ++-- src/server.mjs | 4 ++-- src/site.mjs | 10 +++++++++- test/accounts.test.mjs | 2 +- test/site.test.mjs | 4 ++-- 7 files changed, 19 insertions(+), 11 deletions(-) diff --git a/bun.lock b/bun.lock index 3bcca31..9813331 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,7 @@ "@logicsrc/validators": "0.6.0", "@modelcontextprotocol/sdk": "1.30.0", "@profullstack/bridges": "0.2.0", - "@profullstack/footer": "0.2.5", + "@profullstack/footer": "0.4.0", "@profullstack/hqtui": "0.10.3", "@profullstack/stack": "0.4.0", "@profullstack/threatcrush": "0.13.30", @@ -146,7 +146,7 @@ "@profullstack/emailer": ["@profullstack/emailer@1.0.3", "", {}, "sha512-VyAtCcfnSI6LOwJvWogCr3ZNmEsaZw+iuWrqnosLsdcKsbRyCrCmXigMNZJZEjlgYlR+lK//PwNPG+xtCFvWAw=="], - "@profullstack/footer": ["@profullstack/footer@0.2.5", "", { "peerDependencies": { "react": ">=18" }, "optionalPeers": ["react"], "bin": { "profullstack-footer": "bin/profullstack-footer.js" } }, "sha512-/CKwFP3m+qM0ZFTC9Rj11fgClutJzeYliX44Qp4qwrv2JgA/1k4tVOB3mhkPhaeckUhnfg5UyESDI1uoWr9c2w=="], + "@profullstack/footer": ["@profullstack/footer@0.4.0", "", { "peerDependencies": { "react": ">=18" }, "optionalPeers": ["react"], "bin": { "profullstack-footer": "bin/profullstack-footer.js" } }, "sha512-EJZnMUQmWDi9Gzx6BAjogloeaPFFSSCHtHJuC9dsXiOz8M87hfn6Z+Zx9AMYWk8s6xemPrPlkXKGB3PZugkcEA=="], "@profullstack/form-guard": ["@profullstack/form-guard@0.1.1", "", {}, "sha512-RGLGfQhjq40f+JqEhSmsJnS0I5sFWjYdrYx4VYtkUqz8xvioN2yyR+6uFK0mnDAStKrYytlngg3meUsrChm/OQ=="], diff --git a/package.json b/package.json index 7d709bc..a34c09b 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "@logicsrc/validators": "0.6.0", "@modelcontextprotocol/sdk": "1.30.0", "@profullstack/bridges": "0.2.0", - "@profullstack/footer": "0.2.5", + "@profullstack/footer": "0.4.0", "@profullstack/hqtui": "0.10.3", "@profullstack/stack": "0.4.0", "@profullstack/threatcrush": "0.13.30", diff --git a/src/auth-routes.mjs b/src/auth-routes.mjs index 07d25e2..1fc8b83 100644 --- a/src/auth-routes.mjs +++ b/src/auth-routes.mjs @@ -14,7 +14,7 @@ import { TTL, formatUserCode, } from './accounts.mjs'; -import { escapeHtml as e, pageShell } from './site.mjs'; +import { escapeHtml as e, FOOTER_SCRIPT_SRC, pageShell } from './site.mjs'; // Paths a third party may call cross-origin: no cookies are read on them. export const OPEN_CORS = [ @@ -394,7 +394,7 @@ export function mountAccounts(app, { store, origin, mailer, formGuard = {}, brid // The consent form posts here and is redirected to the app; form-action must allow it. res.set( 'Content-Security-Policy', - `default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: https://built.indie.pit.world https://pwamart.com; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self' ${target.origin}`, + `default-src 'self'; script-src 'self' ${FOOTER_SCRIPT_SRC}; style-src 'self'; img-src 'self' data: https://built.indie.pit.world https://pwamart.com; connect-src 'self' https://pit.world; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self' https://pit.world ${target.origin}`, ); page(res, { title: `Sign in to ${request.client.name}`, diff --git a/src/server.mjs b/src/server.mjs index ff76a29..d9dbb6f 100644 --- a/src/server.mjs +++ b/src/server.mjs @@ -9,7 +9,7 @@ import { openStore, ROOT } from './store.mjs'; import { W3bsError, manifestJsonSchema } from './core.mjs'; import { dispatch, operations } from './operations.mjs'; import { createMcp } from './mcp.mjs'; -import { renderPage, scanBadgeSvg, refreshHouseFooter } from './site.mjs'; +import { renderPage, scanBadgeSvg, refreshHouseFooter, FOOTER_SCRIPT_SRC } from './site.mjs'; import { descriptor } from '@profullstack/footer'; import { createScanner } from './scan.mjs'; import { mountAccounts, OPEN_CORS, bearerOf } from './auth-routes.mjs'; @@ -59,7 +59,7 @@ export function createApp({ 'Referrer-Policy': 'strict-origin-when-cross-origin', 'Cache-Control': 'no-store', 'Content-Security-Policy': - "default-src 'self'; script-src 'self' https://crawlproof.com; style-src 'self'; img-src 'self' data: https://built.indie.pit.world https://pwamart.com; connect-src 'self' https://crawlproof.com; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'", + `default-src 'self'; script-src 'self' https://crawlproof.com ${FOOTER_SCRIPT_SRC}; style-src 'self'; img-src 'self' data: https://built.indie.pit.world https://pwamart.com; connect-src 'self' https://crawlproof.com https://pit.world; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self' https://pit.world`, 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()', }); const railwayHealth = diff --git a/src/site.mjs b/src/site.mjs index 833e612..d7b1460 100644 --- a/src/site.mjs +++ b/src/site.mjs @@ -7,7 +7,8 @@ import { FLEET_LIMITS } from './fleet.mjs'; import { TTL } from './accounts.mjs'; import { buildSuite } from './os-suite.mjs'; import { latestOsIso } from './os-releases.mjs'; -import { footerHtml, footerHtmlSync } from '@profullstack/footer'; +import { createHash } from 'node:crypto'; +import { footerHtml, footerHtmlSync, subscribeForm } from '@profullstack/footer'; import { MOSHPIT_EXAMPLES, WEB_EXAMPLES } from './browse-examples.mjs'; const e = (value) => @@ -119,6 +120,13 @@ const SOCIALS = [ // css:false because the CSP is style-src 'self'; its rules live in style.css. const HOUSE_FOOTER = { site: 'https://w3bs.org/', name: 'W3BS', since: 2026, css: false }; let houseFooter = footerHtmlSync(HOUSE_FOOTER); +// The CSP source for the footer's inline newsletter script (wireSubscribe, footer 0.4.0+), as +// 'sha256-...'. The CSP has no 'unsafe-inline' and the footer is a cached string (no per-request +// nonce), but the renderer emits the same script bytes every time, so its hash allows exactly it. +export const FOOTER_SCRIPT_SRC = (() => { + const m = subscribeForm(HOUSE_FOOTER.site).match(/]*>([\s\S]*?)<\/script>/); + return m?.[1] ? `'sha256-${createHash('sha256').update(m[1]).digest('base64')}'` : ''; +})(); export async function refreshHouseFooter() { houseFooter = await footerHtml(HOUSE_FOOTER); } diff --git a/test/accounts.test.mjs b/test/accounts.test.mjs index 98223d4..7defc31 100644 --- a/test/accounts.test.mjs +++ b/test/accounts.test.mjs @@ -375,7 +375,7 @@ test('OAuth 2.1: PKCE code flow for the w3bs CLI, rotating refresh tokens, reuse assert.equal(consent.status, 200); assert.match( consent.headers.get('content-security-policy'), - /form-action 'self' http:\/\/127\.0\.0\.1:49152/, + /form-action 'self' https:\/\/pit\.world http:\/\/127\.0\.0\.1:49152/, ); assert.match(await consent.text(), /w3bs CLI/); // Denied. diff --git a/test/site.test.mjs b/test/site.test.mjs index 0b40364..6fa6820 100644 --- a/test/site.test.mjs +++ b/test/site.test.mjs @@ -128,8 +128,8 @@ test('CrawlProof: only for https://w3bs.org, never on pages that can carry accou const home = await fetch(`${t.base}/`); assert.ok((await home.text()).includes(tag)); const csp = home.headers.get('content-security-policy'); - assert.match(csp, /script-src 'self' https:\/\/crawlproof\.com;/); - assert.match(csp, /connect-src 'self' https:\/\/crawlproof\.com;/); + assert.match(csp, /script-src 'self' https:\/\/crawlproof\.com 'sha256-[A-Za-z0-9+/=]+';/); + assert.match(csp, /connect-src 'self' https:\/\/crawlproof\.com https:\/\/pit\.world;/); for (const path of ['/pricing', '/signup', '/login', '/login?next=%2Ffleet']) assert.ok((await (await fetch(`${t.base}${path}`)).text()).includes(tag), path); // Email links, device codes and OAuth requests travel in the address; machine names on