ANT-2026-BTRNT5A3 · supabase/supabase

broken-access-control high

Severity Claude high · Security research firm - · Maintainer -

Discovered by Claude Mythos Preview

REPORT

Anthropic's analysis of this finding, sealed at approval.

ANT-2026-BTRNT5A3: Server action accepts client-controlled CRM backend targets

submitFormAction is exported from a 'use server' module and receives the entire crm: GoFormCrmConfig object as a positional argument from a client component, without server-side binding or validation. Because Next.js exposes every exported server action as a POST endpoint whose arguments are deserialised from the request body, an attacker can invoke the action directly with an arbitrary crm structure. buildCrmConfig then attaches the deployer's real NOTION_FORMS_API_KEY, Customer.io and HubSpot credentials to the attacker-chosen database_id, formGuid, event, columnMap and staticProperties, and fires authenticated writes. Raw provider error bodies are reflected back at lines 96/164, giving the attacker an enumeration oracle for which Notion databases and HubSpot forms the credentials can reach.

Target

Project: supabase/supabase
Location: packages/marketing/src/go/actions/submitForm.ts:62
Discovery: static analysis — not yet dynamically reproduced

Technical Details

The root cause is treating a server-action positional argument as trusted configuration: the crm object is passed from a 'use client' caller rather than being closed over with .bind() in a Server Component or resolved server-side, and it is not schema-validated on entry. Any client that can POST to the action endpoint therefore controls every field that selects the downstream CRM target, while the server unconditionally attaches its real provider secrets before dispatching the request.

Reproduction

  1. Load the marketing site and extract the Next.js server-action identifier for submitFormAction from the client bundle (or capture a legitimate form submission).
  2. Craft a POST body where crm.notion.database_id points to a different Notion database shared with the same integration, and set columnMap/staticProperties to arbitrary column values.
  3. Submit the request; the server attaches NOTION_FORMS_API_KEY and creates the page in the attacker-chosen database.
  4. Iterate database_id (and HubSpot formGuid / Customer.io event) values and read the reflected provider error strings at lines 96/164 to enumerate which targets the credentials can reach.
  5. Repeat to fire arbitrary Customer.io track events or submit to arbitrary HubSpot forms under the site's credentials.

[No reproducer or sanitizer output attached — request from security-cvd@anthropic.com if needed.]

Suggested Fix

Bind or resolve the CRM configuration on the server (e.g. submitFormAction.bind(null, pageCrmConfig) in a Server Component, or look it up from a page identifier) so the client can only influence the values map; validate the resolved config against an allow-list of known database_id/formGuid values before issuing provider calls; and stop reflecting raw provider error bodies to the client.

Acknowledgement

This vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged by the Anthropic security team in collaboration with Anthropic Research. Please direct questions to security-cvd@anthropic.com and reference ANT-2026-BTRNT5A3.


Reference: ANT-2026-BTRNT5A3
Anthropic CVD Policy: https://www.anthropic.com/coordinated-vulnerability-disclosure

UPSTREAM FIX

The change that resolved this finding.

diff --git a/apps/www/_go/lead-gen/example-lead-gen.tsx b/apps/www/_go/lead-gen/example-lead-gen.tsx
index 81c1187744b1b..8c25c972e7fbf 100644
--- a/apps/www/_go/lead-gen/example-lead-gen.tsx
+++ b/apps/www/_go/lead-gen/example-lead-gen.tsx
@@ -224,6 +224,7 @@ alter table posts enable row level security;`,
     },
     {
       type: 'form',
+      id: 'form',
       title: 'Get in touch',
       description: 'Fill out the form below and our team will get back to you shortly.',
       fields: [
diff --git a/apps/www/instrumentation.ts b/apps/www/instrumentation.ts
index 3063091e693ee..ce4795aa99850 100644
--- a/apps/www/instrumentation.ts
+++ b/apps/www/instrumentation.ts
@@ -3,6 +3,8 @@ import * as Sentry from '@sentry/nextjs'
 export async function register() {
   if (process.env.NEXT_RUNTIME === 'nodejs') {
     await import('./sentry.server.config')
+    const { registerFormCrmResolver } = await import('./lib/registerFormCrm')
+    registerFormCrmResolver()
   }
 
   if (process.env.NEXT_RUNTIME === 'edge') {
diff --git a/apps/www/lib/go.ts b/apps/www/lib/go.ts
index 3876928737c05..2e0ac425ce4ee 100644
--- a/apps/www/lib/go.ts
+++ b/apps/www/lib/go.ts
@@ -1,3 +1,5 @@
+import { validateGoPageInvariants } from 'marketing'
+
 import rawPages from '@/_go'
 import { goPageSchema, type GoPage } from '@/types/go'
 
@@ -14,6 +16,13 @@ export function getAllGoPages(): GoPage[] {
       )
     }
 
+    const invariantErrors = validateGoPageInvariants(result.data)
+    if (invariantErrors.length > 0) {
+      throw new Error(
+        `Invalid go page definition (slug: "${result.data.slug}"):\n${invariantErrors.map((m) => `  - ${m}`).join('\n')}`
+      )
+    }
+
     if (seenSlugs.has(result.data.slug)) {
       throw new Error(`Duplicate slug "${result.data.slug}" in _go registry`)
     }
diff --git a/apps/www/lib/registerFormCrm.ts b/apps/www/lib/registerFormCrm.ts
new file mode 100644
index 0000000000000..d18d8a097b4d3
--- /dev/null
+++ b/apps/www/lib/registerFormCrm.ts
@@ -0,0 +1,23 @@
+import 'server-only'
+
+import { setFormCrmResolver } from 'marketing'
+
+import { getGoPageBySlug } from './go'
+
+/**
+ * Wire the marketing form server action to look up the trusted CRM config for
+ * a `{ slug, formId }` pair from the in-process page registry. Without this,
+ * `submitFormAction` fails closed and rejects every submission. See
+ * PRODSEC-120 for why the CRM config must never come from the client.
+ */
+export function registerFormCrmResolver() {
+  setFormCrmResolver(({ slug, formId }) => {
+    const page = getGoPageBySlug(slug)
+    if (!page || !('sections' in page) || !page.sections) return undefined
+
+    const section = page.sections.find((s) => s.type === 'form' && s.id === formId)
+    if (!section || section.type !== 'form') return undefined
+
+    return section.crm
+  })
+}
diff --git a/packages/marketing/src/forms/MarketingForm.tsx b/packages/marketing/src/forms/MarketingForm.tsx
index 10022734a36e1..82e77495a5113 100644
--- a/packages/marketing/src/forms/MarketingForm.tsx
+++ b/packages/marketing/src/forms/MarketingForm.tsx
@@ -16,11 +16,20 @@ import {
 import type { z } from 'zod'
 
 import { submitFormAction } from '../go/actions/submitForm'
-import { formCrmConfigSchema, formFieldSchema, type GoFormFieldShowWhen } from '../go/schemas'
+import { formFieldSchema, type GoFormFieldShowWhen } from '../go/schemas'
 
 /** Input-shape field type — fields with Zod defaults (`half`, `required`) are optional here. */
 export type MarketingFormField = z.input<typeof formFieldSchema>
-export type MarketingFormCrmConfig = z.input<typeof formCrmConfigSchema>
+
+/**
+ * Opaque reference the client posts back to the server action. The server
+ * resolves this to the trusted CRM config from the page registry; the client
+ * never sees or controls the actual CRM target (database id, form GUID, etc.).
+ */
+export interface MarketingFormRef {
+  slug: string
+  formId: string
+}
 
 /**
  * Evaluate a `showWhen` rule against the current form values. All supplied
@@ -52,8 +61,13 @@ export interface MarketingFormProps {
   successMessage?: string
   /** URL to redirect the user to after a successful submission. Overrides `successMessage`. */
   successRedirect?: string
-  /** CRM fan-out config — submits to HubSpot, Customer.io, and/or Notion in parallel. */
-  crm?: MarketingFormCrmConfig
+  /**
+   * Server-side form reference. When set, submissions are posted to
+   * `submitFormAction` with this ref; the server looks up the trusted CRM
+   * config from the page registry. When omitted, the form logs values in dev
+   * and does nothing in production (useful for previews).
+   */
+  formRef?: MarketingFormRef
   /** Wraps the form in a styled card (border + padding). Defaults to `true`. */
   card?: boolean
   /** Extra class names applied to the outer wrapper. */
@@ -63,10 +77,9 @@ export interface MarketingFormProps {
 type SubmitState = 'idle' | 'loading' | 'success' | 'error'
 
 /** Build the sessionStorage key used to block double-submits of the same email to the same form. */
-function dedupeKey(crm: MarketingFormCrmConfig | undefined, email: string): string | null {
-  const formId = crm?.hubspot?.formGuid ?? crm?.notion?.database_id
-  if (!formId || !email) return null
-  return `marketing-form-submitted:${formId}:${email.trim().toLowerCase()}`
+function dedupeKey(formRef: MarketingFormRef | undefined, email: string): string | null {
+  if (!formRef || !email) return null
+  return `marketing-form-submitted:${formRef.slug}:${formRef.formId}:${email.trim().toLowerCase()}`
 }
 
 function FieldInput({
@@ -166,7 +179,7 @@ export default function MarketingForm({
   disclaimer,
   successMessage,
   successRedirect,
-  crm,
+  formRef,
   card = true,
   className,
 }: MarketingFormProps) {
@@ -210,9 +223,9 @@ export default function MarketingForm({
       Object.entries(values).filter(([name]) => visibleFieldNames.has(name))
     )
 
-    if (!crm) {
+    if (!formRef) {
       if (process.env.NODE_ENV === 'development') {
-        console.log('[marketing/form] No CRM configured — form values:', submittedValues)
+        console.log('[marketing/form] No formRef configured — form values:', submittedValues)
       }
       return
     }
@@ -226,7 +239,7 @@ export default function MarketingForm({
       submittedValues['emailAddress'] ??
       submittedValues['email_address'] ??
       ''
-    const sessionKey = dedupeKey(crm, emailValue)
+    const sessionKey = dedupeKey(formRef, emailValue)
     if (sessionKey && typeof window !== 'undefined') {
       try {
         if (window.sessionStorage.getItem(sessionKey)) {
@@ -250,7 +263,7 @@ export default function MarketingForm({
     const honeypot = honeypotRef.current?.value ?? ''
 
     try {
-      const result = await submitFormAction(crm, submittedValues, {
+      const result = await submitFormAction(formRef, submittedValues, {
         pageUri,
         pageName,
         honeypot,
diff --git a/packages/marketing/src/forms/index.ts b/packages/marketing/src/forms/index.ts
index 974e7091b4bae..a7de322a30840 100644
--- a/packages/marketing/src/forms/index.ts
+++ b/packages/marketing/src/forms/index.ts
@@ -1,9 +1,5 @@
 export { default as MarketingForm } from './MarketingForm'
-export type {
-  MarketingFormCrmConfig,
-  MarketingFormField,
-  MarketingFormProps,
-} from './MarketingForm'
+export type { MarketingFormField, MarketingFormProps, MarketingFormRef } from './MarketingForm'
 
 export { default as HubSpotFormEmbed } from './HubSpotFormEmbed'
 export type { HubSpotFormEmbedProps } from './HubSpotFormEmbed'
diff --git a/packages/marketing/src/go/actions/formCrmResolver.ts b/packages/marketing/src/go/actions/formCrmResolver.ts
new file mode 100644
index 0000000000000..994040a2b469e
--- /dev/null
+++ b/packages/marketing/src/go/actions/formCrmResolver.ts
@@ -0,0 +1,32 @@
+import 'server-only'
+
+import type { GoFormCrmConfig } from '../schemas'
+
+export interface FormRef {
+  slug: string
+  formId: string
+}
+
+export type FormCrmResolver = (
+  ref: FormRef
+) => GoFormCrmConfig | undefined | Promise<GoFormCrmConfig | undefined>
+
+let resolver: FormCrmResolver | null = null
+
… (truncated)

https://github.com/supabase/supabase/commit/47d85e5235f4f547f9239ff8ea36acc7f389cf7d

TIMELINE

Dates from discovery through public reveal.

  1. 2026-05-14 Reported to tracker
  2. 2026-05-14 Maintainer acknowledged
  3. 2026-05-15 Sent to maintainer
  4. 2026-05-26 Patch released
  5. 2026-08-17 Publicly revealed
PROVENANCE

SHA-3-512 hash:

7334a6ee4f96e9391bad4fd6fc61c9031fc10172a8e48a573dc1345665c0f9b67b2d490eac6bb956067bce68acab746f67bebae3a2c1e46c5df8a416795a35a5

Committed 2026-05-17 17:56 PT

Revealed 2026-08-17 17:36 PT

Verify (download preimage.json)

Show preimage JSON
{
  "ant_id": "ANT-2026-BTRNT5A3",
  "bug_class": "broken-access-control",
  "claude_severity": "high",
  "commit_sha": null,
  "created_at": "2026-05-14T22:03:33+00:00",
  "description": "`submitFormAction` is exported from a `'use server'` module and receives the entire `crm: GoFormCrmConfig` object as a positional argument from a client component, without server-side binding or validation. Because Next.js exposes every exported server action as a POST endpoint whose arguments are deserialised from the request body, an attacker can invoke the action directly with an arbitrary `crm` structure. `buildCrmConfig` then attaches the deployer's real `NOTION_FORMS_API_KEY`, Customer.io and HubSpot credentials to the attacker-chosen `database_id`, `formGuid`, `event`, `columnMap` and `staticProperties`, and fires authenticated writes. Raw provider error bodies are reflected back at lines 96/164, giving the attacker an enumeration oracle for which Notion databases and HubSpot forms the credentials can reach.",
  "discovered_at": "2026-05-10T00:00:00+00:00",
  "location": "packages/marketing/src/go/actions/submitForm.ts:62",
  "poc_sha256": null,
  "preimage_version": 1,
  "project": "supabase/supabase",
  "reproduction": [
    "1. Load the marketing site and extract the Next.js server-action identifier for `submitFormAction` from the client bundle (or capture a legitimate form submission).",
    "2. Craft a POST body where `crm.notion.database_id` points to a different Notion database shared with the same integration, and set `columnMap`/`staticProperties` to arbitrary column values.",
    "3. Submit the request; the server attaches `NOTION_FORMS_API_KEY` and creates the page in the attacker-chosen database.",
    "4. Iterate `database_id` (and HubSpot `formGuid` / Customer.io `event`) values and read the reflected provider error strings at lines 96/164 to enumerate which targets the credentials can reach.",
    "5. Repeat to fire arbitrary Customer.io track events or submit to arbitrary HubSpot forms under the site's credentials."
  ],
  "technical_details": "The root cause is treating a server-action positional argument as trusted configuration: the `crm` object is passed from a `'use client'` caller rather than being closed over with `.bind()` in a Server Component or resolved server-side, and it is not schema-validated on entry. Any client that can POST to the action endpoint therefore controls every field that selects the downstream CRM target, while the server unconditionally attaches its real provider secrets before dispatching the request.",
  "title": "Server action accepts client-controlled CRM backend targets",
  "vendor_severity": null
}