11import type { AuthenticationResponseJSON , RegistrationResponseJSON } from '@simplewebauthn/server' ;
2+ import { randomBytes } from 'node:crypto' ;
23import { readdirSync , readFileSync } from 'node:fs' ;
34import { dirname , extname , join } from 'node:path' ;
45import { fileURLToPath } from 'node:url' ;
@@ -28,8 +29,20 @@ const TYPES: Record<string, string> = {
2829 '.html' : 'text/html; charset=utf-8' ,
2930} ;
3031
32+ // pit.world: the footer's newsletter form POSTs to pit.world/api/subscribe (connect-src),
33+ // falling back to a plain form POST to pit.world/subscribe without script (form-action).
3134export const PAGE_CSP =
32- "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:*" ;
35+ "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:*" ;
36+
37+ /** PAGE_CSP plus a per-response nonce in script-src, for the footer's inline subscribe script. */
38+ export function pageCsp ( nonce ?: string ) : string {
39+ return nonce ? PAGE_CSP . replace ( "script-src 'self'" , `script-src 'self' 'nonce-${ nonce } '` ) : PAGE_CSP ;
40+ }
41+
42+ /** A fresh CSP nonce for one response. */
43+ export function newNonce ( ) : string {
44+ return randomBytes ( 16 ) . toString ( 'base64' ) ;
45+ }
3346
3447export interface WebOptions {
3548 mailer : Mailer ;
@@ -92,8 +105,8 @@ export const SITE_URL = 'https://submitfleet.com/';
92105 * /assets/pfs-footer.css (css: false here), so the footer does not depend on
93106 * style-src allowing inline styles.
94107 */
95- export function siteFooter ( links : { label : string ; href : string } [ ] ) : Promise < string > {
96- return footerHtml ( { site : SITE_URL , links, css : false } ) ;
108+ export function siteFooter ( links : { label : string ; href : string } [ ] , nonce ?: string ) : Promise < string > {
109+ return footerHtml ( { site : SITE_URL , links, css : false , nonce } ) ;
97110}
98111
99112function safeNext ( raw : string | undefined ) : string {
@@ -109,8 +122,8 @@ export function mountWeb(app: Hono<{ Variables: { actor: Actor } }>, fleet: Subm
109122 const o = origin ( c ) ;
110123 return { origin : o , rpID : new URL ( o ) . hostname , rpName : 'SubmitFleet' } ;
111124 } ;
112- const html = async ( c : Context , body : string , status = 200 ) => {
113- c . header ( 'content-security-policy' , PAGE_CSP ) ;
125+ const html = async ( c : Context , body : string , status = 200 , nonce ?: string ) => {
126+ c . header ( 'content-security-policy' , pageCsp ( nonce ) ) ;
114127 c . header ( 'cache-control' , 'no-store' ) ;
115128 return c . html ( await withPitFooter ( body ) , status as 200 ) ;
116129 } ;
@@ -153,23 +166,25 @@ export function mountWeb(app: Hono<{ Variables: { actor: Actor } }>, fleet: Subm
153166 // ── Pages ──
154167 app . get ( '/' , async ( c ) => {
155168 const stats = await fleet . catalogStats ( ) ;
169+ const nonce = newNonce ( ) ;
156170 const body = page ( 'index.html' )
157- . replace ( '{{FOOTER}}' , await siteFooter ( [ { label : 'Docs' , href : '/docs' } , { label : 'GitHub' , href : 'https://github.com/profullstack/submitfleet' } , { label : 'Sign in' , href : '/login' } ] ) )
171+ . replace ( '{{FOOTER}}' , await siteFooter ( [ { label : 'Docs' , href : '/docs' } , { label : 'GitHub' , href : 'https://github.com/profullstack/submitfleet' } , { label : 'Sign in' , href : '/login' } ] , nonce ) )
158172 . replaceAll ( '{{TOTAL}}' , String ( stats . total ) )
159173 . replaceAll ( '{{FREE}}' , String ( stats . free ) )
160174 . replaceAll ( '{{DIRECTORIES}}' , String ( ( stats . by_channel . directory ?? 0 ) + ( stats . by_channel . citation ?? 0 ) ) )
161175 . replaceAll ( '{{LAUNCH}}' , String ( stats . by_channel [ 'product-launch-site' ] ?? 0 ) )
162176 . replaceAll ( '{{AUTOMATED}}' , String ( stats . automated ) ) ;
163- return html ( c , body ) ;
177+ return html ( c , body , 200 , nonce ) ;
164178 } ) ;
165179 // Sign-up asks for the profile fields (the form has its own email box); sign-in never does.
166180 const signupFields = renderProfileFields ( { fields : [ 'first_name' , 'last_name' , 'company_domain' , 'job_title' , 'linkedin_url' ] } ) ;
167181 app . get ( '/signup' , ( c ) => html ( c , page ( 'auth.html' ) . replace ( '{{PROFILE_FIELDS}}' , signupFields ) ) ) ;
168182 app . get ( '/login' , ( c ) => html ( c , page ( 'auth.html' ) . replace ( '{{PROFILE_FIELDS}}' , '' ) ) ) ;
169183 app . get ( '/auth/link' , ( c ) => html ( c , page ( 'link.html' ) ) ) ;
170- app . get ( '/docs' , async ( c ) =>
171- html ( c , page ( 'docs.html' ) . replace ( '{{FOOTER}}' , await siteFooter ( [ { label : 'Home' , href : '/' } , { label : 'GitHub' , href : 'https://github.com/profullstack/submitfleet' } ] ) ) ) ,
172- ) ;
184+ app . get ( '/docs' , async ( c ) => {
185+ const nonce = newNonce ( ) ;
186+ return html ( c , page ( 'docs.html' ) . replace ( '{{FOOTER}}' , await siteFooter ( [ { label : 'Home' , href : '/' } , { label : 'GitHub' , href : 'https://github.com/profullstack/submitfleet' } ] , nonce ) ) , 200 , nonce ) ;
187+ } ) ;
173188 app . get ( '/.well-known/openwebring.json' , ( ) => descriptorResponse ( { site : SITE_URL , name : 'SubmitFleet' } ) ) ;
174189 const appShell = async ( c : Context ) => {
175190 const user = await auth . sessionUser ( fleet . db , getCookie ( c , auth . SESSION_COOKIE ) ) ;
0 commit comments