ANT-2026-BTRNT5A3 · supabase/supabase
broken-access-control high
Severity Claude high · Security research firm - · Maintainer -
Discovered by Claude Mythos Preview
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
- Load the marketing site and extract the Next.js server-action identifier for
submitFormActionfrom the client bundle (or capture a legitimate form submission). - Craft a POST body where
crm.notion.database_idpoints to a different Notion database shared with the same integration, and setcolumnMap/staticPropertiesto arbitrary column values. - Submit the request; the server attaches
NOTION_FORMS_API_KEYand creates the page in the attacker-chosen database. - Iterate
database_id(and HubSpotformGuid/ Customer.ioevent) values and read the reflected provider error strings at lines 96/164 to enumerate which targets the credentials can reach. - 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
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
Dates from discovery through public reveal.
- 2026-05-14 Reported to tracker
- 2026-05-14 Maintainer acknowledged
- 2026-05-15 Sent to maintainer
- 2026-05-26 Patch released
- 2026-08-17 Publicly revealed
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
}