ANT-2026-Y9HESEM8 · supabase/supabase
auth-bypass medium
Severity Claude medium · Security research firm - · Maintainer -
Discovered by Claude Mythos Preview
Anthropic's analysis of this finding, sealed at approval.
ANT-2026-Y9HESEM8: AI data-sharing opt-in bypassed by mismatched org and project
In apps/studio/pages/api/ai/sql/generate-v4.ts (and similarly in pages/api/ai/code/complete.ts), the handler reads orgSlug and projectRef as independent body parameters. It computes aiOptInLevel solely from getOrgAIDetails({orgSlug}) and then enables schema/log/data-reading tools that execute against projectRef. Because the server never checks that projectRef is owned by orgSlug's organization, a user who belongs to two orgs can supply a permissive org's slug alongside a restricted org's project. Additionally, the HIPAA/sensitive-project downgrade to 'disabled' is enforced only client-side; the server fetches those flags but uses them only for tracing. The result is that schema, logs, and row data from an opted-out or HIPAA-governed project can be sent to OpenAI.
Target
Project: supabase/supabase
Location: apps/studio/pages/api/ai/sql/generate-v4.ts:130
Discovery: static analysis — not yet dynamically reproduced
Technical Details
Root cause is a missing ownership check: aiOptInLevel is derived from the orgSlug parameter independently of the projectRef parameter that the AI tools actually query, so the authorization decision and the resource accessed are decoupled. The server also fails to replicate the client-side useOrgAiOptInLevel logic that forces opt-in to 'disabled' for HIPAA orgs with sensitive projects, leaving that control enforceable only in the browser.
Reproduction
- Identify Org A with aiOptInLevel 'schema_and_log_and_data' and Org B with opt-in 'disabled' (or HIPAA add-on + sensitive project).
- POST to /api/ai/sql/generate-v4 with body {orgSlug: 'org-a', projectRef: '', connectionString: '', messages: [...]}.
- Server reads Org A's full-data opt-in and enables list_tables / get_logs / execute_sql tools.
- Tools execute against Org B's project using the user's token, and the resulting schema, logs, and row data are included in the prompt sent to OpenAI.
[No reproducer or sanitizer output attached — request from security-cvd@anthropic.com if needed.]
Suggested Fix
On the server, resolve the owning organization from projectRef (ignore or validate the client-supplied orgSlug) and derive aiOptInLevel from that org; additionally, enforce the HIPAA add-on / sensitive-project downgrade to 'disabled' server-side before enabling any data-accessing tools.
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-Y9HESEM8.
Reference: ANT-2026-Y9HESEM8
Anthropic CVD Policy: https://www.anthropic.com/coordinated-vulnerability-disclosure
The change that resolved this finding.
diff --git a/apps/studio/lib/ai/ai-details.test.ts b/apps/studio/lib/ai/ai-details.test.ts
index 6102aaf445dc1..56e4b61b3a2b9 100644
--- a/apps/studio/lib/ai/ai-details.test.ts
+++ b/apps/studio/lib/ai/ai-details.test.ts
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { getOrgAIDetails, getProjectAIDetails } from './ai-details'
+import { getAIDetails } from './ai-details'
vi.mock('@/data/organizations/organizations-query', () => ({
getOrganizations: vi.fn(),
@@ -32,10 +32,14 @@ vi.mock('@/data/entitlements/entitlements-query', () => ({
const AUTH = 'Bearer token'
const HEADERS = { 'Content-Type': 'application/json', Authorization: AUTH }
+const ORG_SLUG = 'test-org'
+const PROJECT_REF = 'test-project'
-describe('getOrgAIDetails', () => {
+describe('getAIDetails', () => {
let mockGetOrganizations: ReturnType<typeof vi.fn>
let mockGetOrgSubscription: ReturnType<typeof vi.fn>
+ let mockGetProjectDetail: ReturnType<typeof vi.fn>
+ let mockGetProjectSettings: ReturnType<typeof vi.fn>
let mockGetAiOptInLevel: ReturnType<typeof vi.fn>
let mockSubscriptionHasHipaaAddon: ReturnType<typeof vi.fn>
let mockCheckEntitlement: ReturnType<typeof vi.fn>
@@ -45,6 +49,8 @@ describe('getOrgAIDetails', () => {
const orgsQuery = await import('@/data/organizations/organizations-query')
const subscriptionQuery = await import('@/data/subscriptions/org-subscription-query')
+ const projectQuery = await import('@/data/projects/project-detail-query')
+ const settingsQuery = await import('@/data/config/project-settings-v2-query')
const aiHook = await import('@/hooks/misc/useOrgOptedIntoAi')
const subscriptionUtils =
await import('@/components/interfaces/Billing/Subscription/Subscription.utils')
@@ -52,22 +58,33 @@ describe('getOrgAIDetails', () => {
mockGetOrganizations = vi.mocked(orgsQuery.getOrganizations)
mockGetOrgSubscription = vi.mocked(subscriptionQuery.getOrgSubscription)
+ mockGetProjectDetail = vi.mocked(projectQuery.getProjectDetail)
+ mockGetProjectSettings = vi.mocked(settingsQuery.getProjectSettings)
mockGetAiOptInLevel = vi.mocked(aiHook.getAiOptInLevel)
mockSubscriptionHasHipaaAddon = vi.mocked(subscriptionUtils.subscriptionHasHipaaAddon)
mockCheckEntitlement = vi.mocked(entitlementsQuery.checkEntitlement)
+ mockGetOrganizations.mockResolvedValue([
+ { id: 1, slug: ORG_SLUG, plan: { id: 'pro' }, opt_in_tags: [] },
+ ])
+ mockGetProjectDetail.mockResolvedValue({
+ ref: PROJECT_REF,
+ region: 'us-east-1',
+ organization_id: 1,
+ })
+ mockGetProjectSettings.mockResolvedValue({ is_sensitive: false })
mockGetOrgSubscription.mockResolvedValue({ addons: [] })
mockSubscriptionHasHipaaAddon.mockReturnValue(false)
mockCheckEntitlement.mockResolvedValue({ hasAccess: false })
- })
-
- it('returns org-level fields', async () => {
- mockGetOrganizations.mockResolvedValue([
- { id: 1, slug: 'test-org', plan: { id: 'pro' }, opt_in_tags: [] },
- ])
mockGetAiOptInLevel.mockReturnValue('schema')
+ })
- const result = await getOrgAIDetails({ orgSlug: 'test-org', authorization: AUTH })
+ it('returns the resolved posture when the project belongs to the org', async () => {
+ const result = await getAIDetails({
+ orgSlug: ORG_SLUG,
+ projectRef: PROJECT_REF,
+ authorization: AUTH,
+ })
expect(result).toEqual({
aiOptInLevel: 'schema',
@@ -75,132 +92,199 @@ describe('getOrgAIDetails', () => {
hasHipaaAddon: false,
orgId: 1,
planId: 'pro',
+ region: 'us-east-1',
+ isSensitive: false,
})
})
- it('returns hasAccessToAdvanceModel true when entitlement grants access', async () => {
- mockGetOrganizations.mockResolvedValue([
- { id: 1, slug: 'test-org', plan: { id: 'pro' }, opt_in_tags: [] },
- ])
- mockGetAiOptInLevel.mockReturnValue('schema')
- mockCheckEntitlement.mockResolvedValue({ hasAccess: true })
-
- const result = await getOrgAIDetails({ orgSlug: 'test-org', authorization: AUTH })
-
- expect(result.hasAccessToAdvanceModel).toBe(true)
- })
-
- it('returns hasHipaaAddon from subscription', async () => {
- mockGetOrganizations.mockResolvedValue([
- { id: 1, slug: 'test-org', plan: { id: 'enterprise' }, opt_in_tags: [] },
- ])
- mockGetAiOptInLevel.mockReturnValue('schema')
- mockSubscriptionHasHipaaAddon.mockReturnValue(true)
-
- const result = await getOrgAIDetails({ orgSlug: 'test-org', authorization: AUTH })
-
- expect(result.hasHipaaAddon).toBe(true)
- })
-
it('calls getAiOptInLevel with the matched org opt_in_tags', async () => {
const opt_in_tags = ['AI_SQL_GENERATOR_OPT_IN']
mockGetOrganizations.mockResolvedValue([
- { id: 1, slug: 'test-org', plan: { id: 'pro' }, opt_in_tags },
+ { id: 1, slug: ORG_SLUG, plan: { id: 'pro' }, opt_in_tags },
])
- mockGetAiOptInLevel.mockReturnValue('schema')
- await getOrgAIDetails({ orgSlug: 'test-org', authorization: AUTH })
+ await getAIDetails({ orgSlug: ORG_SLUG, projectRef: PROJECT_REF, authorization: AUTH })
expect(mockGetAiOptInLevel).toHaveBeenCalledWith(opt_in_tags)
})
- it('forwards authorization headers to all fetches', async () => {
- mockGetOrganizations.mockResolvedValue([
- { id: 1, slug: 'test-org', plan: { id: 'pro' }, opt_in_tags: [] },
- ])
- mockGetAiOptInLevel.mockReturnValue('schema')
+ it('returns hasAccessToAdvanceModel true when the entitlement grants access', async () => {
+ mockCheckEntitlement.mockResolvedValue({ hasAccess: true })
- await getOrgAIDetails({ orgSlug: 'test-org', authorization: AUTH })
+ const result = await getAIDetails({
+ orgSlug: ORG_SLUG,
+ projectRef: PROJECT_REF,
+ authorization: AUTH,
+ })
- expect(mockGetOrganizations).toHaveBeenCalledWith({ headers: HEADERS })
- expect(mockGetOrgSubscription).toHaveBeenCalledWith({ orgSlug: 'test-org' }, undefined, HEADERS)
+ expect(result.hasAccessToAdvanceModel).toBe(true)
})
it('finds the correct org when multiple orgs are returned', async () => {
mockGetOrganizations.mockResolvedValue([
{ id: 1, slug: 'org-1', plan: { id: 'free' }, opt_in_tags: [] },
- { id: 2, slug: 'test-org', plan: { id: 'pro' }, opt_in_tags: [] },
+ { id: 2, slug: ORG_SLUG, plan: { id: 'pro' }, opt_in_tags: [] },
])
- mockGetAiOptInLevel.mockReturnValue('schema')
+ mockGetProjectDetail.mockResolvedValue({
+ ref: PROJECT_REF,
+ region: 'us-east-1',
+ organization_id: 2,
+ })
- const result = await getOrgAIDetails({ orgSlug: 'test-org', authorization: AUTH })
+ const result = await getAIDetails({
+ orgSlug: ORG_SLUG,
+ projectRef: PROJECT_REF,
+ authorization: AUTH,
+ })
expect(result.orgId).toBe(2)
expect(result.planId).toBe('pro')
})
-})
-describe('getProjectAIDetails', () => {
- let mockGetProjectDetail: ReturnType<typeof vi.fn>
- let mockGetProjectSettings: ReturnType<typeof vi.fn>
+ it('forwards authorization headers to all fetches', async () => {
+ await getAIDetails({ orgSlug: ORG_SLUG, projectRef: PROJECT_REF, authorization: AUTH })
- beforeEach(async () => {
- vi.clearAllMocks()
+ expect(mockGetOrganizations).toHaveBeenCalledWith({ headers: HEADERS })
+ expect(mockGetOrgSubscription).toHaveBeenCalledWith({ orgSlug: ORG_SLUG }, undefined, HEADERS)
+ expect(mockCheckEntitlement).toHaveBeenCalledWith(
+ ORG_SLUG,
+ 'assistant.advance_model',
+ undefined,
+ HEADERS
+ )
+ expect(mockGetProjectDetail).toHaveBeenCalledWith(
+ { ref: PROJECT_REF, skipWake: true },
+ undefined,
+ HEADERS
+ )
+ expect(mockGetProjectSettings).toHaveBeenCalledWith(
+ { projectRef: PROJECT_REF },
+ undefined,
+ HEADERS
+ )
+ })
- const projectQuery = await import('@/data/projects/project-detail-query')
- const settingsQuery = await import('@/data/config/project-settings-v2-query')
+ describe('when the project does not belong to the org', () => {
+ beforeEach((
… (truncated)https://github.com/supabase/supabase/commit/b04d14856ab0c3db99cbf98389baa18fb4467450
Dates from discovery through public reveal.
- 2026-05-14 Reported to tracker
- 2026-05-14 Maintainer acknowledged
- 2026-05-15 Sent to maintainer
- 2026-08-09 Patch released
- 2026-08-18 Publicly revealed
SHA-3-512 hash:
8dfbbcc57f9b0dd5b5e0abe44654b299a3fabc46e8e1ceed32be939cc4b6081bd72983fb94a018f19948c82aae736fa603dfe8c717114e9f5ee62bdea0a32659
Committed 2026-05-17 17:55 PT
Revealed 2026-08-18 07:11 PT
Verify (download preimage.json)
Show preimage JSON
{
"ant_id": "ANT-2026-Y9HESEM8",
"bug_class": "auth-bypass",
"claude_severity": "medium",
"commit_sha": null,
"created_at": "2026-05-14T22:03:44+00:00",
"description": "In apps/studio/pages/api/ai/sql/generate-v4.ts (and similarly in pages/api/ai/code/complete.ts), the handler reads orgSlug and projectRef as independent body parameters. It computes aiOptInLevel solely from getOrgAIDetails({orgSlug}) and then enables schema/log/data-reading tools that execute against projectRef. Because the server never checks that projectRef is owned by orgSlug's organization, a user who belongs to two orgs can supply a permissive org's slug alongside a restricted org's project. Additionally, the HIPAA/sensitive-project downgrade to 'disabled' is enforced only client-side; the server fetches those flags but uses them only for tracing. The result is that schema, logs, and row data from an opted-out or HIPAA-governed project can be sent to OpenAI.",
"discovered_at": "2026-05-10T00:00:00+00:00",
"location": "apps/studio/pages/api/ai/sql/generate-v4.ts:130",
"poc_sha256": null,
"preimage_version": 1,
"project": "supabase/supabase",
"reproduction": [
"1. Identify Org A with aiOptInLevel 'schema_and_log_and_data' and Org B with opt-in 'disabled' (or HIPAA add-on + sensitive project).",
"2. POST to /api/ai/sql/generate-v4 with body {orgSlug: 'org-a', projectRef: '<org-b-project-ref>', connectionString: '<org-b-conn>', messages: [...]}.",
"3. Server reads Org A's full-data opt-in and enables list_tables / get_logs / execute_sql tools.",
"4. Tools execute against Org B's project using the user's token, and the resulting schema, logs, and row data are included in the prompt sent to OpenAI."
],
"technical_details": "Root cause is a missing ownership check: aiOptInLevel is derived from the orgSlug parameter independently of the projectRef parameter that the AI tools actually query, so the authorization decision and the resource accessed are decoupled. The server also fails to replicate the client-side useOrgAiOptInLevel logic that forces opt-in to 'disabled' for HIPAA orgs with sensitive projects, leaving that control enforceable only in the browser.",
"title": "AI data-sharing opt-in bypassed by mismatched org and project",
"vendor_severity": null
}