ANT-2026-AM0R21ZY · supabase/supabase

broken-access-control medium

Severity Claude medium · Security research firm - · Maintainer -

Discovered by Claude Mythos Preview

REPORT

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

ANT-2026-AM0R21ZY: HIPAA sensitive-project AI block enforced only client-side

The client hook useOrgAiOptInLevel forces aiOptInLevel to 'disabled' when an org has the HIPAA add-on and the project is flagged sensitive, but the server-side getAiDetails() in lib/ai/ai-details.ts only reads the org's opt_in_tags and never checks project sensitivity or HIPAA status. All AI API routes (/api/ai/sql/generate-v4, /api/ai/sql/complete-v2, etc.) rely on getAiDetails() for enforcement. An authenticated member of a HIPAA org can therefore replay a direct request to these endpoints with a sensitive project's projectRef, and the server will compute an org-level opt-in like 'schema' and send the sensitive project's table/column metadata to OpenAI, bypassing the UI-only guard.

Target

Project: supabase/supabase
Location: apps/studio/lib/ai/ai-details.ts:27
Discovery: static analysis — not yet dynamically reproduced

Technical Details

getAiDetails() calls getAiOptInLevel(selectedOrg?.opt_in_tags) and returns immediately; it never fetches the project's is_sensitive flag or the org's HIPAA add-on status, so the downgrade-to-'disabled' logic that exists in the client hook is absent server-side. Because the API handlers trust this value, the compliance block is effectively client-side-only and trivially bypassed by calling the API directly.

Reproduction

  1. Observe that the Assistant UI is greyed out for the sensitive project.
  2. Open browser devtools and copy a previous request to /api/ai/sql/generate-v4 (or similar AI route).
  3. Replay the request directly with the sensitive project's projectRef.
  4. Server-side getAiDetails reads only org opt_in_tags, returns a non-disabled aiOptInLevel, and the handler forwards the sensitive schema/metadata to OpenAI.

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

Suggested Fix

Make getAiDetails() fetch the project's sensitivity flag and the org's HIPAA add-on status and force aiOptInLevel = 'disabled' when both are true, mirroring the client hook so enforcement happens server-side.

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-AM0R21ZY.


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

UPSTREAM FIX

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

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-08-09 Patch released
  5. 2026-08-18 Publicly revealed
PROVENANCE

SHA-3-512 hash:

865e16fe8de1cf11c3d4c31e6303eb13ff8c84a3a62fd8b597f17baac56cc6c4a772f06ffa12a454c9a8dac7c7b69f3d4cfe594d2ea28eb6fd37af330936bdd6

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-AM0R21ZY",
  "bug_class": "broken-access-control",
  "claude_severity": "medium",
  "commit_sha": null,
  "created_at": "2026-05-14T22:03:41+00:00",
  "description": "The client hook useOrgAiOptInLevel forces aiOptInLevel to 'disabled' when an org has the HIPAA add-on and the project is flagged sensitive, but the server-side getAiDetails() in lib/ai/ai-details.ts only reads the org's opt_in_tags and never checks project sensitivity or HIPAA status. All AI API routes (/api/ai/sql/generate-v4, /api/ai/sql/complete-v2, etc.) rely on getAiDetails() for enforcement. An authenticated member of a HIPAA org can therefore replay a direct request to these endpoints with a sensitive project's projectRef, and the server will compute an org-level opt-in like 'schema' and send the sensitive project's table/column metadata to OpenAI, bypassing the UI-only guard.",
  "discovered_at": "2026-05-10T00:00:00+00:00",
  "location": "apps/studio/lib/ai/ai-details.ts:27",
  "poc_sha256": null,
  "preimage_version": 1,
  "project": "supabase/supabase",
  "reproduction": [
    "1. Observe that the Assistant UI is greyed out for the sensitive project.",
    "2. Open browser devtools and copy a previous request to /api/ai/sql/generate-v4 (or similar AI route).",
    "3. Replay the request directly with the sensitive project's projectRef.",
    "4. Server-side getAiDetails reads only org opt_in_tags, returns a non-disabled aiOptInLevel, and the handler forwards the sensitive schema/metadata to OpenAI."
  ],
  "technical_details": "getAiDetails() calls getAiOptInLevel(selectedOrg?.opt_in_tags) and returns immediately; it never fetches the project's is_sensitive flag or the org's HIPAA add-on status, so the downgrade-to-'disabled' logic that exists in the client hook is absent server-side. Because the API handlers trust this value, the compliance block is effectively client-side-only and trivially bypassed by calling the API directly.",
  "title": "HIPAA sensitive-project AI block enforced only client-side",
  "vendor_severity": null
}