ANT-2026-AM0R21ZY · supabase/supabase
broken-access-control medium
Severity Claude medium · Security research firm - · Maintainer -
Discovered by Claude Mythos Preview
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
- Observe that the Assistant UI is greyed out for the sensitive project.
- Open browser devtools and copy a previous request to /api/ai/sql/generate-v4 (or similar AI route).
- Replay the request directly with the sensitive project's projectRef.
- 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
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:
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
}