From 28b63469fd3c30bc3072fc679127f6691083edb6 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 26 Oct 2026 18:39:34 +0000 Subject: [PATCH] footer 0.4.0: inline newsletter feedback; script hash + 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 (bun.lock 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 has no 'unsafe-inline' and the footer HTML is cached across requests, so a per-request nonce cannot reach it. ring-footer.ts now exposes the 'sha256-…' hashes of the footer's inline scripts (recomputed when the cached footer changes) and the header middleware adds them to script-src. - connect-src adds https://pit.world (the JSON POST). - form-action adds https://pit.world (the no-script fallback POST, which was silently dropped before). tsc --noEmit and bun test (17 pass) locally. Co-Authored-By: Claude Opus 5.5 --- bun.lock | 4 ++-- package.json | 2 +- src/ring-footer.ts | 22 ++++++++++++++++++++++ src/server.ts | 11 +++++++++-- 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/bun.lock b/bun.lock index f3c5333..07235f6 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.32.2", "@profullstack/coinpay-onboard": "^0.2.0", - "@profullstack/footer": "^0.2.6", + "@profullstack/footer": "^0.4.0", "@profullstack/hqtui": "^0.11.0", "hono": "^4.13.14", "qrcode": "^1.5.4", @@ -30,7 +30,7 @@ "@profullstack/coinpay-onboard": ["@profullstack/coinpay-onboard@0.2.0", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.17.0", "zod": "^3.25.0" }, "bin": { "coinpay-onboard": "bin/coinpay-onboard" } }, "sha512-FxIiTefcAswyCyEapEcC+vSai3IZcOPPxaBGnT7L/VPGfZJYjH1kHoN6DCRMBQx0l2IbJ2aDLqrYUhqRUaI2cA=="], - "@profullstack/footer": ["@profullstack/footer@0.2.6", "", { "peerDependencies": { "react": ">=18" }, "optionalPeers": ["react"], "bin": { "profullstack-footer": "bin/profullstack-footer.js" } }, "sha512-BKwbDszAc7e0mYIBiPjgr/5WS8WyDt8IW718s/zW/8pCWOIB1BGwSNs6fmImRSo5kqIjQKUVbXp22jZQ5GEipA=="], + "@profullstack/footer": ["@profullstack/footer@0.4.0", "", { "peerDependencies": { "react": ">=18" }, "optionalPeers": ["react"], "bin": { "profullstack-footer": "bin/profullstack-footer.js" } }, "sha512-EJZnMUQmWDi9Gzx6BAjogloeaPFFSSCHtHJuC9dsXiOz8M87hfn6Z+Zx9AMYWk8s6xemPrPlkXKGB3PZugkcEA=="], "@profullstack/hqtui": ["@profullstack/hqtui@0.11.0", "", { "bin": { "hqtui": "bin/hqtui.mjs" } }, "sha512-kvpruktJsxwcQ8VYxdxDJn6dtPoWOMuOtr87DZbVV0KIrm7BBF5HRf37gA5Am/ReWd/ayEn9rvmHN6rVFTFALA=="], diff --git a/package.json b/package.json index c306ad0..88b6c10 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.32.2", "@profullstack/coinpay-onboard": "^0.2.0", - "@profullstack/footer": "^0.2.6", + "@profullstack/footer": "^0.4.0", "@profullstack/hqtui": "^0.11.0", "hono": "^4.13.14", "qrcode": "^1.5.4", diff --git a/src/ring-footer.ts b/src/ring-footer.ts index 6d60459..2478475 100644 --- a/src/ring-footer.ts +++ b/src/ring-footer.ts @@ -8,6 +8,7 @@ * template in the background every 15 minutes, so a page never waits on the CDN and * a footer release reaches us without a redeploy. A failed read keeps the last copy. */ +import { createHash } from 'node:crypto' import { descriptor, footerHtml, footerHtmlSync, type FooterOptions } from '@profullstack/footer' /** The ring member URL (the web2 hostname; hops are measured from it). */ @@ -30,6 +31,27 @@ let cached = footerHtmlSync(OPTIONS) /** The footer HTML to print in the layout. */ export const ringFooter = (): string => cached +let hashedFor = '' +let hashes = '' + +/** + * CSP 'sha256-…' sources for the footer's inline scripts (0.4.0+: the newsletter + * form's wireSubscribe, which posts to pit.world and reports inline). script-src has + * no 'unsafe-inline' and the footer is cached across requests, so a per-request nonce + * cannot reach it; a hash of the exact script text can. Recomputed when the cache changes. + */ +export function ringFooterScriptHashes(): string { + if (hashedFor !== cached) { + const out: string[] = [] + for (const m of cached.matchAll(/]*)?>([\s\S]*?)<\/script>/g)) { + out.push(`'sha256-${createHash('sha256').update(m[1] ?? '', 'utf8').digest('base64')}'`) + } + hashes = out.join(' ') + hashedFor = cached + } + return hashes +} + /** /.well-known/openwebring.json */ export const ringDescriptor = () => descriptor({ site: RING_SITE, name: 'ad.network' }) diff --git a/src/server.ts b/src/server.ts index 4cc8fc9..6ba1d75 100644 --- a/src/server.ts +++ b/src/server.ts @@ -14,7 +14,7 @@ import { Sealer, Store, type Campaign, type DepositRow, type Site, type User } f import { adPage, advertisePage, depositPage, helpPage, homePage, installScript, mePage, messagePage, pitchPage, publishPage, sitePage, SITE, type Ctx } from './html.js' import { startPitFooterRefresh } from './pit-footer.js' import { RING_PLACEHOLDER, ringBar, startRingRefresh } from './ring.js' -import { ringDescriptor, startRingFooterRefresh } from './ring-footer.js' +import { ringDescriptor, ringFooterScriptHashes, startRingFooterRefresh } from './ring-footer.js' import { AdNetwork, newApiKey, UserError, type Config } from './service.js' import { VERSION } from './version.js' @@ -25,7 +25,10 @@ const SESSION = 'an_session' const SCOPES = ['openid', 'profile', 'email', 'wallet:read'] const SECURITY_HEADERS: Record = { - 'content-security-policy': "default-src 'none'; style-src 'self' 'unsafe-inline'; script-src 'self' https://crawlproof.com; img-src 'self' data: https://built.indie.pit.world https://pwamart.com; connect-src 'self' https://crawlproof.com; frame-src https://crawlproof.com; form-action 'self' https://coinpayportal.com; base-uri 'none'; frame-ancestors 'none'", + // https://pit.world: the footer's newsletter form posts to pit.world/api/subscribe + // (connect-src) and falls back to a plain POST to pit.world/subscribe (form-action). + // script-src also gets the footer's inline-script hashes, added per response below. + 'content-security-policy': "default-src 'none'; style-src 'self' 'unsafe-inline'; script-src 'self' https://crawlproof.com; img-src 'self' data: https://built.indie.pit.world https://pwamart.com; connect-src 'self' https://crawlproof.com https://pit.world; frame-src https://crawlproof.com; form-action 'self' https://coinpayportal.com https://pit.world; base-uri 'none'; frame-ancestors 'none'", 'referrer-policy': 'strict-origin-when-cross-origin', 'x-content-type-options': 'nosniff', 'permissions-policy': 'interest-cohort=(), browsing-topics=(), camera=(), microphone=(), geolocation=()', @@ -89,6 +92,10 @@ export function createApp(deps: Deps) { app.use('*', async (c, next) => { await next() for (const [k, v] of Object.entries(SECURITY_HEADERS)) c.header(k, v) + const footerScripts = ringFooterScriptHashes() + if (footerScripts) { + c.header('content-security-policy', SECURITY_HEADERS['content-security-policy']!.replace("script-src 'self'", `script-src 'self' ${footerScripts}`)) + } }) app.get('/health', (c) => c.json({ ok: true, version: VERSION }))