{"uuid": "2a3599bb-b625-437c-98f8-c95fd2ebea66", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "GHSA-4r6h-8v6p-xvw6", "type": "seen", "source": "https://gist.github.com/jmcveen/2d2b8e075a88abf882695479e38437d1", "content": "# Front-End Adversarial Security Audit + Hardening \u2014 Plan (Ready to Ship)\n\n**Origin:** Joe founder-priority \"Safety first\" \u2014 full OWASP-grade adversarial audit of the live internet-facing front end (our software-demo funnel on growthmastery.ai + ALL published user funnel pages).\n**Diagnosis model:** Opus (claude-opus-4-8), 5 parallel adversarial reviewers, read-only.\n**Date:** 2026-06-10. **Live users on the platform \u2014 every fix must be demonstrably non-breaking.**\n\n## Coordination\nThe DNS-verify task (`VERIFY-dns-live-registration-crm`) is doing a clean test registration through the live opt-in form on growthmastery.ai and needs the shared agent-browser session. Active browser-based attack probing (XSS payloads / injection / rate-limit through the live form) is sequenced AFTER that PASS to avoid clobbering the shared session and polluting the CRM. Read-only external recon (headers, secret/source-map exposure) was already run and is reflected below.\n\n## What live recon already confirmed\n- `app.growthmastery.ai` + `growthmastery.ai` return ONLY `strict-transport-security`. No CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy on the app/marketing surface. (Funnel pages DO get a per-request CSP via middleware; everything else gets nothing.)\n- No `.env` / `.git` / `next.config.js` / source-map exposure (all 404). HSTS present. \u2705\n\n---\n\n## Severity-ranked findings + hardening spec (end-state; Luma decides PR breakdown &amp; parallelization)\n\n### CRITICAL\n\n**SEC-1 \u2014 Stored XSS on public lead-facing pages (registration / enrollment / call-booking).**\n`components/public/public-page-wrapper.tsx:90` renders the raw `html_content` DB column straight into the parent DOM \u2014 no iframe, no sanitizer. Call sites: `app/[username]/[slug]/page.tsx:499, 618, 656`. Every *other* public render path sanitizes via `sanitizeFullPageHtmlServer` or isolates in a sandboxed `srcDoc`; this one does neither.\n- Exploit: a customer (or anyone who signs up) saves `` into a page's `html_content`; it executes in **every visiting lead's browser**, same-origin with the app.\n- **Fix (reuse, not build):** wrap `html_content` with the existing `sanitizeFullPageHtmlServer` (`lib/sanitize-server.ts:246`) at the three call sites, OR route through the sandboxed published-page renderer. Match the pattern already used at `app/[username]/[slug]/page.tsx:750/1365/1703`.\n- Regression guard: confirm legitimate published pages still render their full rich content after sanitization (walk a real published registration + enrollment page).\n\n**SEC-2 \u2014 Webhook signature verification is a no-op (email + SMS follow-up).**\n`app/api/followup/webhooks/email/route.ts:36` and `followup/webhooks/sms/route.ts:30` call `emailProvider.verifyWebhook(...)` / `provider.verifyWebhook(...)` \u2014 but every provider impl (`postmark-provider.ts:142`, `email-provider.ts:85`, `gmail-provider.ts:146`, `sms-provider.ts:151`) is hardcoded `return true`, and the routes do NO route-level auth. Unauthenticated POST accepted.\n- Exploit: `curl` a fake Postmark bounce/complaint to mark any contact bounced/opted-out (kill a competitor's deliverability) or fake opens/clicks to poison analytics; spoof a Twilio STOP to opt-out any phone.\n- **Fix (reuse, not build):** implement real verification \u2014 Postmark Basic-Auth (reuse the working fail-closed pattern already in `app/api/webhooks/postmark/route.ts:107`), Twilio `X-Twilio-Signature` HMAC over full URL + sorted params. Fail-closed when the secret is configured; reject unsigned.\n- **NEEDS JOE/OPS (credential gate \u2014 batched for Atlas):** before this goes live, confirm `POSTMARK` webhook creds + `TWILIO_AUTH_TOKEN` are set in Vercel prod env. If a secret is MISSING when fail-closed ships, legitimate provider webhooks (bounce processing, SMS opt-outs) will start being rejected \u2014 a silent regression. Luma must log loudly + the acceptance walk must confirm a real provider webhook still processes.\n\n**SEC-3 \u2014 Stored XSS via SVG upload to public buckets.**\n`app/api/pages/upload-image/route.ts:24` allows `image/svg+xml`; `app/api/registration/upload-headshot/route.ts` allows any `image/*` (client-supplied MIME, never byte-sniffed) into PUBLIC supabase buckets returned via `getPublicUrl`.\n- Exploit: upload `evil.svg` with embedded JS; the public `*.supabase.co/.../public/...svg` URL executes script on the supabase.co origin and is embeddable on funnel pages.\n- **Fix:** sanitize SVG with DOMPurify (`USE_PROFILES:{svg:true}`) on upload OR force `Content-Disposition: attachment` + byte-sniff MIME server-side + restrict `image/*` to a raster allowlist. **Do NOT hard-reject SVG outright** \u2014 existing customers may have SVG logos; sanitize-on-upload preserves them. (Regression: existing brand logos must still render.)\n\n### HIGH\n\n**SEC-4 \u2014 Missing security headers site-wide.** `next.config.ts` has no `headers()` block; CSP/headers only set for 2-segment funnel pages in middleware. App root, `/login`, `/funnel-builder/*`, `/settings`, marketing get nothing.\n- **Fix:** add `async headers()` to `next.config.ts` with site-wide low-risk defaults: `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy: camera=(), microphone=(), geolocation=()`. Do NOT add a static `Content-Security-Policy` (the middleware nonce CSP owns funnel routes \u2014 must compose cleanly).\n- Regression guard: published funnels on custom domains + the sandboxed iframe renderer must still work (X-Frame-Options must not break legitimate funnel embedding \u2014 verify custom-domain funnel + preview iframe render).\n\n**SEC-5 \u2014 Unauthenticated chat injection into any watch room.** `app/api/watch-room/messages/route.ts:110-168` POST: no auth, no token; attacker-supplied `funnel_project_id`+`contact_id`+`message_content` inserted via service-role as `moderation_status:\"clean\", is_visible:true`.\n- **Fix (reuse):** require the per-contact HMAC token via `verifyContactToken` (`lib/utils/contact-token.ts:53`, already used by `track-watch`) + validate the contact belongs to the funnel before insert. Wire the token on the public client that posts chat. Also fix the `select(\"*\")` over-fetch at `:57` \u2192 explicit public columns.\n- Regression guard: a real visitor must still be able to post a watch-room message end-to-end.\n\n**SEC-6 \u2014 Unauthenticated engagement tampering.** `app/api/followup/track/route.ts:26-107` POST accepts any `prospect_id` with NO token (unlike `track-watch`).\n- **Fix (reuse):** require `verifyContactToken`; reject without it. Wire the token on the caller.\n- Regression guard: real engagement tracking from a public page still fires.\n\n**SEC-7 \u2014 SSRF in unauthenticated `intake/crawl`.** `app/api/intake/crawl/route.ts:182` POST (IP-rate-limited only) validates protocol but never calls `validateUrl`; raw-`fetch`es `robots.txt` + up to 50 user-controlled pages.\n- Exploit: `{\"url\":\"http://169.254.169.254/latest/meta-data/\"}` \u2192 cloud-metadata exfil / internal-service hits.\n- **Fix (reuse):** call existing `validateUrl` (`lib/scraping/fetch-utils.ts:336`) on the entry URL AND every crawled link before fetching (the sibling `intake/scrape` + `scrape/brand-colors` already do this).\n\n**SEC-8 \u2014 SSRF blocklist bypassable (DNS rebinding + numeric IP forms).** `lib/scraping/fetch-utils.ts:292-331 isBlockedHostname` string-matches IP literals only; no DNS resolution, so a hostname resolving to `169.254.169.254` slips through, as do decimal/octal/hex IPs (`http://2130706433/`). `app/api/ai-editor/url-import/route.ts:29-42` is weaker still.\n- **Fix:** resolve the hostname and re-check the resolved IP against the blocklist (or pin via custom DNS lookup); normalize numeric IP forms before the regex test. Apply to url-import too.\n\n**SEC-9 \u2014 `Embed.tsx mode='html'` relies on a bypassable regex sanitizer.** `lib/editor/primitives/Embed.tsx:57` `dangerouslySetInnerHTML` trusts the upstream regex-based `sanitizeGeneratedHtml` (`lib/ai-editor/generator.ts:113`). Regex HTML sanitizers are mutation-XSS-bypassable.\n- **Fix:** sanitize at the render boundary with the DOM-based `sanitize-html` (`sanitizeHtmlServer` / the same path `sanitizeFullPageHtmlServer` uses) instead of trusting an upstream regex pass.\n\n### MEDIUM (include the low-risk ones in this same hardening; Luma's discretion)\n- **SEC-10** `lookup_public_page` RPC returns `to_jsonb(fp)` (entire funnel_projects row incl. `meta_pixel_id`/internal config) to anon `/p/[id]` visitors (`supabase/migrations/20260126000001_add_page_lookup_rpc.sql`). Fix: return an explicit public-safe column allowlist. (New migration; pair a Layer-2 round-trip test.)\n- **SEC-11** vapi (`app/api/vapi/webhook/route.ts:48`) + cloudflare (`app/api/webhooks/cloudflare/route.ts:44`) webhooks skip verification when the secret/header is absent (\"missing secret silently disables verification\"). Fix: fail-closed \u2014 401 when secret or signature missing (mirror `content-studio/transcription/webhook`).\n- **SEC-12** `app/api/discovery/conversations/route.ts:13-85` GET: unauth read of full chat transcripts keyed only on client-supplied `visitorId`+`agentId`. Fix: sign `visitorId` into a token at creation, verify here.\n- **SEC-13** `checkout/validate-code` + `discount-codes/validate` `select('*')` leak price/discount detail; `discount-codes/validate` lacks the `original_price`-from-client guard its sibling has. Fix: select only needed fields; mirror the checkout guard.\n- **SEC-14** `app/api/internal/contact-debug/[contactId]/route.ts` has no per-owner check (relies solely on `INTERNAL_DEBUG_SECRET`); docstring promises an owner path that doesn't exist. Fix: implement the authenticated-owner path or confirm not reachable in prod + harden the secret.\n- **Defense-in-depth:** `middleware.ts` does not default-deny `/api/**` \u2014 any new route is public-by-default until its handler self-gates. Add a default-deny API guard (allowlist the intentionally-public `/api/public/*`, webhooks, cron). Luma's discretion whether to land in this initiative or log for follow-up.\n\n### VERIFY-ONLY (NOT a fix \u2014 measure twice)\n- **SEC-V1 \u2014 followup_prospects RLS.** The original `USING(true)` policy (`20250126000004:65`) was the headline RLS reviewer finding, BUT migration `20260130000002:301-305` already DROPs it and recreates as `Service role can manage prospects USING (auth.jwt()-&gt;&gt;'role'='service_role')`. **Appears already remediated.** Luma: after `supabase db reset` (which replays all migrations in order), run `select polname, qual, roles from pg_policies where tablename='followup_prospects'` and CONFIRM the live policy is service_role-only with no surviving `USING(true)`. If confirmed, no change. If a `USING(true)` survives, fix it. Report the confirmation in the PR.\n\n### LOW (LOG ONLY \u2014 out of this run's scope, future planning)\nPublic `WITH CHECK(true)` INSERT on tracking tables (data-pollution, not read leak); watch-room/polls config disclosure; bulk-email/message-preview self-XSS; SVG-in-`` (non-exploitable today); `sanitizeImportedHtml` deliberately preserves `` but is safe only inside the no-`allow-same-origin` sandbox (add a guard/assert that it's never rendered outside that sandbox). \u2192 log to `_bmad-output/post-ship-discoveries/frontend-security.md`.\n\n---\n\n## Blast-Radius Declaration\n- **Shared infra touched:** `next.config.ts` (global headers \u2014 composes with middleware funnel CSP), public webhook handlers, public tracking/engagement endpoints, public page render path, upload routes, SSRF fetch util.\n- **Tables read/written:** `followup_prospects` (verify-only), `funnel_projects` (RPC column allowlist), `watch_room_messages`, `followup_events`/engagement, `funnel_analytics` \u2014 all org-scoped; no global tables altered destructively.\n- **RLS:** SEC-10 adds a new migration (RPC return shape) \u2014 pair a Layer-2 round-trip test per migration-dependency-gate. SEC-V1 is read-only confirmation.\n- **Routes changed:** the specific handlers above. Role surfaces: anon (public funnel render, tracking, webhooks) + authenticated (upload).\n- **Dependent readers/writers:** the public funnel client (must send `verifyContactToken` for watch-room chat + followup/track \u2014 wire client-side in same PR), the email/SMS provider webhooks (must have prod secrets), custom-domain funnel embedding (X-Frame-Options compose check).\n\n## Best-practice / reuse markers (NO new core functionality)\n- `sanitizeFullPageHtmlServer` (`lib/sanitize-server.ts:246`) \u2014 reuse for SEC-1, SEC-9.\n- `validateUrl` (`lib/scraping/fetch-utils.ts:336`) \u2014 reuse for SEC-7, SEC-8.\n- `verifyContactToken` (`lib/utils/contact-token.ts:53`) \u2014 reuse for SEC-5, SEC-6.\n- Postmark fail-closed Basic-Auth pattern (`app/api/webhooks/postmark/route.ts:107`) \u2014 reuse for SEC-2 email.\n- Meta `X-Hub-Signature-256` HMAC pattern (`webhooks/meta/leadgen`) \u2014 reference for SEC-2/SEC-11 signing.\n\n## Acceptance Contract (regression gate \u2014 NON-NEGOTIABLE)\n1. **Full firewall suite green locally** (`supabase db reset` + local keys + `pnpm test:firewall`, OFFLINE, AI mocked). Paste result in PR.\n2. **Full test suite green** in CI.\n3. **Post-merge E2E walk on the public funnel** (after DNS-verify clears the shared browser session): render the software-demo funnel + a REAL opt-in registration that lands in GrowthMastery's CRM with correct name/email/source attribution. Prove nothing regressed.\n4. **Per-fix regression guards** (above) all verified: published pages render full content post-sanitize; custom-domain funnel still embeds; real watch-room message + engagement-track still fire with the new token; a real provider webhook still processes.\n5. `walkthrough-verified` label after the agent-browser/Playwright walk. NO LABEL = NO MERGE.\n6. SEC-V1 pg_policies confirmation pasted in PR.\n\n**Do NOT report \"secure\" if ANY customer-facing flow regressed.** Severity-frame the outcome for Atlas \u2192 Joe in plain English.\n\n---\n\n## DEEP-PASS ADDENDUM \u2014 dependency &amp; build hardening (2026-06-10 ~16:35Z, Opus 4.8 sign-off gate)\n\nThe apex deep pass confirmed the subset SEC-1 (=public-page sanitization), SEC-4 (=missing headers),\nSEC-8 (=SSRF blocklist bypass) against the real codebase, and **re-verified strong** (no action):\nRLS/multi-tenant (112 migrations, anon defensively denied, org-scoping firewall suite enforces),\nrate-limiting on every public opt-in endpoint, and the public XSS sinks meta-pixel / fathom returnTo /\nemail-history / island-renderer / upsell+checkout+thankyou templates. It also surfaced these\n**net-new, confirmed** items to fold into this same dispatch:\n\n### DEP-1 \u2014 Next.js DoS advisories (HIGH, non-breaking patch)\n`next@16.0.7`. GHSA-mwv6-3258-q52c (&lt;16.0.9) + GHSA-h25m-26qc-wcjf (&lt;16.0.11): RSC HTTP-request\ndeserialization / Server-Component DoS. **Fix:** bump `next` to `&gt;=16.0.11` (patch within 16.0.x).\nRun `pnpm tsc --noEmit` + `pnpm build` to confirm green.\n\n### DEP-2 \u2014 build-time transitive ReDoS / path-traversal (LOW, build-only)\n`minimatch &lt;9.0.6` (via `@google/genai&gt;\u2026&gt;glob`) ReDoS; `rollup &lt;4.59.0` (via `@sentry/nextjs`)\narbitrary-file-write path traversal. **Fix:** `package.json` `pnpm.overrides`:\n`\"minimatch\": \"&gt;=9.0.6\"`, `\"rollup\": \"&gt;=4.59.0\"`; re-run `pnpm install` + `pnpm audit --prod`.\nNon-breaking (build/dev graph only).\n\n### DEP-3 \u2014 Sentry source maps not explicitly deleted post-upload (LOW)\n`next.config.ts` sets `widenClientFileUpload: true` with no deletion. **Fix:** add\n`sourcemaps: { deleteSourcemapsAfterUpload: true }` to the `withSentryConfig` options so client\n`.map` files aren't left fetchable in prod.\n\n### DEP-4 \u2014 xlsx / SheetJS (HIGH advisory, NEEDS JOE DECISION \u2014 do NOT silently swap)\nPrototype pollution GHSA-4r6h-8v6p-xvw6 + ReDoS GHSA-5pgg-2g8v-p4x9. **No patched version exists on\nthe npm registry** (SheetJS publishes patched builds only via their own CDN). Options: (A) install\nSheetJS from their CDN tarball; (B) migrate to `exceljs`; (C) accept \u2014 xlsx only parses\nowner-uploaded spreadsheets, never anonymous input. Surface to Joe; not auto-fixable this run.\n\n**Regression-gate addition:** after the bumps, `pnpm audit --prod --audit-level=high` must show\nnext/minimatch/rollup cleared (xlsx tracked separately for Joe).\n", "creation_timestamp": "2026-06-10T16:20:08.000000Z"}