diff --git a/src/components/professional/ContactForm.tsx b/src/components/professional/ContactForm.tsx index edf3cbc..c4bb5f2 100644 --- a/src/components/professional/ContactForm.tsx +++ b/src/components/professional/ContactForm.tsx @@ -131,8 +131,18 @@ export default function ContactForm({ turnstileSiteKey }: Props) { // widget rapidly flashing. Instead we leave the widget in its errored // state; the next form submission will reset it for a fresh attempt. // The error surfaces as the token being empty, which onSubmit reports. - 'error-callback': () => { + 'error-callback': (error: unknown) => { turnstileTokenRef.current = ''; + // TEMPORARY DIAGNOSTIC: Turnstile passes an error code/string when the + // widget fails its challenge (e.g. "110200-domain-not-authorized", + // "300030-..."). The 400 on challenges.cloudflare.com/cdn-cgi/ + // challenge-platform/... originates here. Log it so the browser + // console shows the exact reason. Remove once resolved. + console.warn('[contact] turnstile widget error:', error, { + sitekey: turnstileSiteKey, + origin: location.origin, + hostname: location.hostname, + }); }, theme: 'dark', }, diff --git a/src/middleware.ts b/src/middleware.ts index 7beee0d..8cdbd46 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -31,9 +31,22 @@ * need the origin. (SRI is intentionally NOT applied to the Turnstile * loader — see ContactForm.tsx for why.) * - * Everything else is 'self': fonts are self-hosted (Fontsource woff2), images - * are self-hosted (board-game covers fetched at build time, local hero - * portrait), and there is no analytics/tracking. + * Fonts: Fontsource variable fonts (woff2) are self-hosted, but Vite inlines + * font subsets below build.assetsInlineLimit (default 4096 bytes) as + * data:font/woff2 base64 URIs. At least two small cyrillic-ext subsets fall + * under that threshold, so font-src MUST include data: or they are blocked. + * + * Images: board-game covers are self-hosted (fetched at build time by + * scripts/fetch-bgg-covers.mjs) and the hero portrait is local, but TCG deck + * featured-card art is hotlinked live from Archidekt's card-image bucket at + * storage.googleapis.com (see src/lib/archidekt.ts). img-src therefore allows + * that origin in addition to self/data. There is no analytics/tracking. + * + * script-src deliberately does NOT include data: even though Turnstile's + * challenge runtime injects an empty data:application/javascript, resource + * (which CSP therefore blocks — it's functionally inert). Allowing data: + * in script-src would permit arbitrary scripts via data URIs, a serious + * regression. The block is harmless; do not "fix" it by widening script-src. */ import { defineMiddleware } from 'astro:middleware'; @@ -50,10 +63,14 @@ const CONTENT_SECURITY_POLICY = [ // Astro/Tailwind inject inline styles at runtime, so 'unsafe-inline' stays // for now. Tightening this requires hashing the generated styles. "style-src 'self' 'unsafe-inline'", - // All images self-hosted; data: covers inlined SVG / data-URI usage. - "img-src 'self' data:", - // Self-hosted variable fonts (woff2) via @fontsource-variable. - "font-src 'self'", + // Self-hosted images + data: for inlined SVG/data-URIs. The + // storage.googleapis.com origin serves TCG deck featured-card art hotlinked + // from Archidekt's card-image bucket (see src/lib/archidekt.ts). + "img-src 'self' data: https://storage.googleapis.com", + // Self-hosted variable fonts (woff2) via @fontsource-variable. data: is + // required because Vite inlines font subsets under assetsInlineLimit as + // data:font/woff2 base64 URIs. + "font-src 'self' data:", // Same-origin fetch/XHR (the contact form POSTs to /api/contact) plus the // Turnstile siteverify calls the widget makes from the browser. "connect-src 'self' https://challenges.cloudflare.com", diff --git a/src/pages/api/contact.ts b/src/pages/api/contact.ts index 5251c6b..283c792 100644 --- a/src/pages/api/contact.ts +++ b/src/pages/api/contact.ts @@ -94,7 +94,17 @@ async function verifyTurnstile(token: string | undefined, ip: string): Promise