From 4a796e21bd8bc315635789955547ca26a2c921d1 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Sat, 2 May 2026 15:59:32 +0200 Subject: [PATCH 1/3] fix(security): stop SSO domain lookup ID disclosure --- src/composables/useSSORouting.ts | 3 +- src/pages/login.vue | 2 +- .../_backend/private/sso/check-domain.ts | 6 +- .../_backend/private/sso/check-enforcement.ts | 11 ++-- ...2134501_restrict_sso_lookup_rpc_access.sql | 24 +++++++ ...security-definer-execute-hardening.test.ts | 2 + tests/sso-check-domain-response.unit.test.ts | 63 +++++++++++++++++++ tests/sso.test.ts | 10 +-- 8 files changed, 104 insertions(+), 17 deletions(-) create mode 100644 supabase/migrations/20260502134501_restrict_sso_lookup_rpc_access.sql create mode 100644 tests/sso-check-domain-response.unit.test.ts diff --git a/src/composables/useSSORouting.ts b/src/composables/useSSORouting.ts index 1f55ccfcae..b1ef74a2fe 100644 --- a/src/composables/useSSORouting.ts +++ b/src/composables/useSSORouting.ts @@ -3,8 +3,7 @@ import { defaultApiHost, useSupabase } from '~/services/supabase' export interface CheckDomainResponse { has_sso: boolean - provider_id?: string - org_id?: string + enforce_sso?: boolean } export function useSSORouting() { diff --git a/src/pages/login.vue b/src/pages/login.vue index 8e684d5d52..42db7b6cf9 100644 --- a/src/pages/login.vue +++ b/src/pages/login.vue @@ -233,7 +233,7 @@ async function login(form: { email: string, password: string }) { await checkMfa() } -async function checkDomain(email: string): Promise<{ has_sso: boolean, enforce_sso?: boolean, provider_id?: string, org_id?: string }> { +async function checkDomain(email: string): Promise<{ has_sso: boolean, enforce_sso?: boolean }> { try { const { data: sessionData } = await supabase.auth.getSession() const token = sessionData?.session?.access_token diff --git a/supabase/functions/_backend/private/sso/check-domain.ts b/supabase/functions/_backend/private/sso/check-domain.ts index 004e19a578..ce39d43f1a 100644 --- a/supabase/functions/_backend/private/sso/check-domain.ts +++ b/supabase/functions/_backend/private/sso/check-domain.ts @@ -5,7 +5,7 @@ import { CacheHelper } from '../../utils/cache.ts' import { createHono, parseBody, quickError, simpleError, useCors } from '../../utils/hono.ts' import { cloudlog } from '../../utils/logging.ts' import { getClientIP } from '../../utils/rate_limit.ts' -import { emptySupabase } from '../../utils/supabase.ts' +import { supabaseAdmin } from '../../utils/supabase.ts' import { version } from '../../utils/version.ts' // Rate limiting: 10 requests per minute per IP @@ -69,7 +69,7 @@ app.post('/', async (c) => { return quickError(400, 'invalid_email', 'Email must contain a domain') } - const supabase = emptySupabase(c) + const supabase = supabaseAdmin(c) const requestId = c.get('requestId') try { @@ -112,8 +112,6 @@ app.post('/', async (c) => { return c.json({ has_sso: true, enforce_sso: enforcementRow?.enforce_sso === true, - provider_id: legacyRow?.provider_id, - org_id: enforcementRow?.org_id ?? legacyRow?.org_id, }) } catch (err) { diff --git a/supabase/functions/_backend/private/sso/check-enforcement.ts b/supabase/functions/_backend/private/sso/check-enforcement.ts index f743405cb1..7fe208d11e 100644 --- a/supabase/functions/_backend/private/sso/check-enforcement.ts +++ b/supabase/functions/_backend/private/sso/check-enforcement.ts @@ -1,6 +1,6 @@ import { createHono, getClaimsFromJWT, middlewareAuth, parseBody, quickError, useCors } from '../../utils/hono.ts' import { cloudlog } from '../../utils/logging.ts' -import { supabaseWithAuth } from '../../utils/supabase.ts' +import { supabaseAdmin, supabaseWithAuth } from '../../utils/supabase.ts' import { version } from '../../utils/version.ts' export const app = createHono('', version) @@ -49,10 +49,10 @@ app.post('/', middlewareAuth, async (c) => { return quickError(400, 'invalid_email', 'Email must contain a domain') } - const supabase = supabaseWithAuth(c, auth) + const ssoLookupClient = supabaseAdmin(c) try { - const { data: providerData, error: providerError } = await (supabase.rpc as any)('check_domain_sso', { p_domain: domain }) + const { data: providerData, error: providerError } = await (ssoLookupClient.rpc as any)('check_domain_sso', { p_domain: domain }) if (providerError) { cloudlog({ requestId, context: 'check_enforcement - provider query error', error: providerError.message, domain }) return quickError(500, 'query_error', 'Failed to check SSO enforcement') @@ -63,7 +63,7 @@ app.post('/', middlewareAuth, async (c) => { return c.json({ allowed: true }) } - const { data: enforcementData, error: enforcementError } = await (supabase.rpc as any)('get_sso_enforcement_by_domain', { + const { data: enforcementData, error: enforcementError } = await (ssoLookupClient.rpc as any)('get_sso_enforcement_by_domain', { p_domain: domain, }) @@ -87,7 +87,8 @@ app.post('/', middlewareAuth, async (c) => { } // SSO is enforced - check if user is super_admin (break-glass bypass) - const { data: roleData, error: roleError } = await (supabase.from as any)('org_users') + const userClient = supabaseWithAuth(c, auth) + const { data: roleData, error: roleError } = await (userClient.from as any)('org_users') .select('user_right') .eq('org_id', orgId) .eq('user_id', userId) diff --git a/supabase/migrations/20260502134501_restrict_sso_lookup_rpc_access.sql b/supabase/migrations/20260502134501_restrict_sso_lookup_rpc_access.sql new file mode 100644 index 0000000000..78aba87613 --- /dev/null +++ b/supabase/migrations/20260502134501_restrict_sso_lookup_rpc_access.sql @@ -0,0 +1,24 @@ +ALTER FUNCTION public.check_domain_sso(p_domain text) OWNER TO POSTGRES; +REVOKE ALL ON FUNCTION public.check_domain_sso(p_domain text) FROM PUBLIC; +REVOKE ALL ON FUNCTION public.check_domain_sso(p_domain text) FROM ANON; +REVOKE ALL ON FUNCTION public.check_domain_sso(p_domain text) +FROM AUTHENTICATED; +GRANT EXECUTE +ON FUNCTION public.check_domain_sso(p_domain text) +TO SERVICE_ROLE; +COMMENT ON FUNCTION public.check_domain_sso(p_domain text) +IS 'Service-role-only lookup; returns internal SSO identifiers.'; + +ALTER FUNCTION public.get_sso_enforcement_by_domain(p_domain text) +OWNER TO POSTGRES; +REVOKE ALL ON FUNCTION public.get_sso_enforcement_by_domain(p_domain text) +FROM PUBLIC; +REVOKE ALL ON FUNCTION public.get_sso_enforcement_by_domain(p_domain text) +FROM ANON; +REVOKE ALL ON FUNCTION public.get_sso_enforcement_by_domain(p_domain text) +FROM AUTHENTICATED; +GRANT EXECUTE +ON FUNCTION public.get_sso_enforcement_by_domain(p_domain text) +TO SERVICE_ROLE; +COMMENT ON FUNCTION public.get_sso_enforcement_by_domain(p_domain text) +IS 'Service-role-only lookup; returns internal SSO enforcement identifiers.'; diff --git a/tests/security-definer-execute-hardening.test.ts b/tests/security-definer-execute-hardening.test.ts index 80499cf338..1e1c48525f 100644 --- a/tests/security-definer-execute-hardening.test.ts +++ b/tests/security-definer-execute-hardening.test.ts @@ -22,12 +22,14 @@ const INVOKER_PROCS = [ const SERVICE_ONLY_PROCS = [ 'public.apikeys_force_server_key()', 'public.apikeys_strip_plain_key_for_hashed()', + 'public.check_domain_sso(text)', 'public.check_encrypted_bundle_on_insert()', 'public.check_org_hashed_key_enforcement(uuid, public.apikeys)', 'public.cleanup_onboarding_app_data_on_complete()', 'public.delete_old_deleted_versions()', 'public.generate_org_user_stripe_info_on_org_create()', 'public.get_apikey()', + 'public.get_sso_enforcement_by_domain(text)', 'public.noupdate()', 'public.prevent_last_super_admin_binding_delete()', 'public.resync_org_user_role_bindings(uuid, uuid)', diff --git a/tests/sso-check-domain-response.unit.test.ts b/tests/sso-check-domain-response.unit.test.ts new file mode 100644 index 0000000000..34414ef41d --- /dev/null +++ b/tests/sso-check-domain-response.unit.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest' + +const rpcMock = vi.fn() + +vi.mock('../supabase/functions/_backend/utils/logging.ts', () => ({ + cloudlog: () => {}, + cloudlogErr: () => {}, + serializeError: (error: unknown) => String(error), +})) + +vi.mock('../supabase/functions/_backend/utils/supabase.ts', () => ({ + supabaseAdmin: () => ({ + rpc: rpcMock, + }), +})) + +describe('sso check-domain response shape', () => { + it('does not expose internal org or provider identifiers to anonymous callers', async () => { + vi.resetModules() + rpcMock.mockImplementation((functionName: string) => { + if (functionName === 'get_sso_enforcement_by_domain') { + return Promise.resolve({ + data: [{ + enforce_sso: true, + org_id: '00000000-0000-4000-8000-000000000001', + provider_id: '00000000-0000-4000-8000-000000000002', + }], + error: null, + }) + } + + if (functionName === 'check_domain_sso') { + return Promise.resolve({ + data: [{ + has_sso: true, + org_id: '00000000-0000-4000-8000-000000000001', + provider_id: '00000000-0000-4000-8000-000000000003', + }], + error: null, + }) + } + + return Promise.resolve({ data: null, error: null }) + }) + + const { app } = await import('../supabase/functions/_backend/private/sso/check-domain.ts') + + const response = await app.request('http://sso.test/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'cf-connecting-ip': '203.0.113.10', + }, + body: JSON.stringify({ email: 'user@example.com' }), + }) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + has_sso: true, + enforce_sso: true, + }) + }) +}) diff --git a/tests/sso.test.ts b/tests/sso.test.ts index e2a1b04701..8ddbf6a2f7 100644 --- a/tests/sso.test.ts +++ b/tests/sso.test.ts @@ -93,7 +93,9 @@ describe('[POST] /private/sso/check-domain', () => { try { const response = await fetchWithRetry(getEndpointUrl('/private/sso/check-domain'), { method: 'POST', - headers: authHeaders, + headers: { + 'Content-Type': 'application/json', + }, body: JSON.stringify({ email: `user@${expectedDomain.toUpperCase()}` }), }) @@ -101,13 +103,11 @@ describe('[POST] /private/sso/check-domain', () => { const data = await response.json() as { has_sso: boolean enforce_sso?: boolean - provider_id?: string - org_id?: string } expect(data.has_sso).toBe(true) expect(data.enforce_sso).toBe(false) - expect(data.provider_id).toBe(externalProviderId) - expect(data.org_id).toBe(SSO_TEST_ORG_ID) + expect(data).not.toHaveProperty('provider_id') + expect(data).not.toHaveProperty('org_id') const { data: rpcData, error: rpcError } = await (getSupabaseClient().rpc as any)('check_domain_sso', { p_domain: ` ${expectedDomain.toUpperCase()} ` }) expect(rpcError).toBeNull() From 3901a0622037ab72dfbd2b59f13c10ef4e528071 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Sat, 2 May 2026 16:17:37 +0200 Subject: [PATCH 2/3] fix(security): avoid logging SSO identifiers --- supabase/functions/_backend/private/sso/check-domain.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/supabase/functions/_backend/private/sso/check-domain.ts b/supabase/functions/_backend/private/sso/check-domain.ts index ce39d43f1a..4a2a973f6a 100644 --- a/supabase/functions/_backend/private/sso/check-domain.ts +++ b/supabase/functions/_backend/private/sso/check-domain.ts @@ -105,8 +105,6 @@ app.post('/', async (c) => { context: 'check_domain - SSO provider found', domain, enforce_sso: enforcementRow?.enforce_sso, - provider_id: legacyRow?.provider_id, - org_id: enforcementRow?.org_id ?? legacyRow?.org_id, }) return c.json({ From a26b81e1d4630c34f815113c8e91a02244098b3b Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Mon, 4 May 2026 18:59:51 +0200 Subject: [PATCH 3/3] fix(security): keep SSO domain fix minimal --- src/composables/useSSORouting.ts | 3 +- src/pages/login.vue | 2 +- .../_backend/private/sso/check-domain.ts | 6 +- .../_backend/private/sso/check-enforcement.ts | 11 ++-- ...2134501_restrict_sso_lookup_rpc_access.sql | 24 ------- ...security-definer-execute-hardening.test.ts | 2 - tests/sso-check-domain-response.unit.test.ts | 63 ------------------- tests/sso.test.ts | 10 +-- 8 files changed, 17 insertions(+), 104 deletions(-) delete mode 100644 supabase/migrations/20260502134501_restrict_sso_lookup_rpc_access.sql delete mode 100644 tests/sso-check-domain-response.unit.test.ts diff --git a/src/composables/useSSORouting.ts b/src/composables/useSSORouting.ts index b1ef74a2fe..1f55ccfcae 100644 --- a/src/composables/useSSORouting.ts +++ b/src/composables/useSSORouting.ts @@ -3,7 +3,8 @@ import { defaultApiHost, useSupabase } from '~/services/supabase' export interface CheckDomainResponse { has_sso: boolean - enforce_sso?: boolean + provider_id?: string + org_id?: string } export function useSSORouting() { diff --git a/src/pages/login.vue b/src/pages/login.vue index 42db7b6cf9..8e684d5d52 100644 --- a/src/pages/login.vue +++ b/src/pages/login.vue @@ -233,7 +233,7 @@ async function login(form: { email: string, password: string }) { await checkMfa() } -async function checkDomain(email: string): Promise<{ has_sso: boolean, enforce_sso?: boolean }> { +async function checkDomain(email: string): Promise<{ has_sso: boolean, enforce_sso?: boolean, provider_id?: string, org_id?: string }> { try { const { data: sessionData } = await supabase.auth.getSession() const token = sessionData?.session?.access_token diff --git a/supabase/functions/_backend/private/sso/check-domain.ts b/supabase/functions/_backend/private/sso/check-domain.ts index 4a2a973f6a..36aa8eb207 100644 --- a/supabase/functions/_backend/private/sso/check-domain.ts +++ b/supabase/functions/_backend/private/sso/check-domain.ts @@ -5,7 +5,7 @@ import { CacheHelper } from '../../utils/cache.ts' import { createHono, parseBody, quickError, simpleError, useCors } from '../../utils/hono.ts' import { cloudlog } from '../../utils/logging.ts' import { getClientIP } from '../../utils/rate_limit.ts' -import { supabaseAdmin } from '../../utils/supabase.ts' +import { emptySupabase } from '../../utils/supabase.ts' import { version } from '../../utils/version.ts' // Rate limiting: 10 requests per minute per IP @@ -69,7 +69,7 @@ app.post('/', async (c) => { return quickError(400, 'invalid_email', 'Email must contain a domain') } - const supabase = supabaseAdmin(c) + const supabase = emptySupabase(c) const requestId = c.get('requestId') try { @@ -105,6 +105,8 @@ app.post('/', async (c) => { context: 'check_domain - SSO provider found', domain, enforce_sso: enforcementRow?.enforce_sso, + provider_id: legacyRow?.provider_id, + org_id: enforcementRow?.org_id ?? legacyRow?.org_id, }) return c.json({ diff --git a/supabase/functions/_backend/private/sso/check-enforcement.ts b/supabase/functions/_backend/private/sso/check-enforcement.ts index 7fe208d11e..f743405cb1 100644 --- a/supabase/functions/_backend/private/sso/check-enforcement.ts +++ b/supabase/functions/_backend/private/sso/check-enforcement.ts @@ -1,6 +1,6 @@ import { createHono, getClaimsFromJWT, middlewareAuth, parseBody, quickError, useCors } from '../../utils/hono.ts' import { cloudlog } from '../../utils/logging.ts' -import { supabaseAdmin, supabaseWithAuth } from '../../utils/supabase.ts' +import { supabaseWithAuth } from '../../utils/supabase.ts' import { version } from '../../utils/version.ts' export const app = createHono('', version) @@ -49,10 +49,10 @@ app.post('/', middlewareAuth, async (c) => { return quickError(400, 'invalid_email', 'Email must contain a domain') } - const ssoLookupClient = supabaseAdmin(c) + const supabase = supabaseWithAuth(c, auth) try { - const { data: providerData, error: providerError } = await (ssoLookupClient.rpc as any)('check_domain_sso', { p_domain: domain }) + const { data: providerData, error: providerError } = await (supabase.rpc as any)('check_domain_sso', { p_domain: domain }) if (providerError) { cloudlog({ requestId, context: 'check_enforcement - provider query error', error: providerError.message, domain }) return quickError(500, 'query_error', 'Failed to check SSO enforcement') @@ -63,7 +63,7 @@ app.post('/', middlewareAuth, async (c) => { return c.json({ allowed: true }) } - const { data: enforcementData, error: enforcementError } = await (ssoLookupClient.rpc as any)('get_sso_enforcement_by_domain', { + const { data: enforcementData, error: enforcementError } = await (supabase.rpc as any)('get_sso_enforcement_by_domain', { p_domain: domain, }) @@ -87,8 +87,7 @@ app.post('/', middlewareAuth, async (c) => { } // SSO is enforced - check if user is super_admin (break-glass bypass) - const userClient = supabaseWithAuth(c, auth) - const { data: roleData, error: roleError } = await (userClient.from as any)('org_users') + const { data: roleData, error: roleError } = await (supabase.from as any)('org_users') .select('user_right') .eq('org_id', orgId) .eq('user_id', userId) diff --git a/supabase/migrations/20260502134501_restrict_sso_lookup_rpc_access.sql b/supabase/migrations/20260502134501_restrict_sso_lookup_rpc_access.sql deleted file mode 100644 index 78aba87613..0000000000 --- a/supabase/migrations/20260502134501_restrict_sso_lookup_rpc_access.sql +++ /dev/null @@ -1,24 +0,0 @@ -ALTER FUNCTION public.check_domain_sso(p_domain text) OWNER TO POSTGRES; -REVOKE ALL ON FUNCTION public.check_domain_sso(p_domain text) FROM PUBLIC; -REVOKE ALL ON FUNCTION public.check_domain_sso(p_domain text) FROM ANON; -REVOKE ALL ON FUNCTION public.check_domain_sso(p_domain text) -FROM AUTHENTICATED; -GRANT EXECUTE -ON FUNCTION public.check_domain_sso(p_domain text) -TO SERVICE_ROLE; -COMMENT ON FUNCTION public.check_domain_sso(p_domain text) -IS 'Service-role-only lookup; returns internal SSO identifiers.'; - -ALTER FUNCTION public.get_sso_enforcement_by_domain(p_domain text) -OWNER TO POSTGRES; -REVOKE ALL ON FUNCTION public.get_sso_enforcement_by_domain(p_domain text) -FROM PUBLIC; -REVOKE ALL ON FUNCTION public.get_sso_enforcement_by_domain(p_domain text) -FROM ANON; -REVOKE ALL ON FUNCTION public.get_sso_enforcement_by_domain(p_domain text) -FROM AUTHENTICATED; -GRANT EXECUTE -ON FUNCTION public.get_sso_enforcement_by_domain(p_domain text) -TO SERVICE_ROLE; -COMMENT ON FUNCTION public.get_sso_enforcement_by_domain(p_domain text) -IS 'Service-role-only lookup; returns internal SSO enforcement identifiers.'; diff --git a/tests/security-definer-execute-hardening.test.ts b/tests/security-definer-execute-hardening.test.ts index 1e1c48525f..80499cf338 100644 --- a/tests/security-definer-execute-hardening.test.ts +++ b/tests/security-definer-execute-hardening.test.ts @@ -22,14 +22,12 @@ const INVOKER_PROCS = [ const SERVICE_ONLY_PROCS = [ 'public.apikeys_force_server_key()', 'public.apikeys_strip_plain_key_for_hashed()', - 'public.check_domain_sso(text)', 'public.check_encrypted_bundle_on_insert()', 'public.check_org_hashed_key_enforcement(uuid, public.apikeys)', 'public.cleanup_onboarding_app_data_on_complete()', 'public.delete_old_deleted_versions()', 'public.generate_org_user_stripe_info_on_org_create()', 'public.get_apikey()', - 'public.get_sso_enforcement_by_domain(text)', 'public.noupdate()', 'public.prevent_last_super_admin_binding_delete()', 'public.resync_org_user_role_bindings(uuid, uuid)', diff --git a/tests/sso-check-domain-response.unit.test.ts b/tests/sso-check-domain-response.unit.test.ts deleted file mode 100644 index 34414ef41d..0000000000 --- a/tests/sso-check-domain-response.unit.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -const rpcMock = vi.fn() - -vi.mock('../supabase/functions/_backend/utils/logging.ts', () => ({ - cloudlog: () => {}, - cloudlogErr: () => {}, - serializeError: (error: unknown) => String(error), -})) - -vi.mock('../supabase/functions/_backend/utils/supabase.ts', () => ({ - supabaseAdmin: () => ({ - rpc: rpcMock, - }), -})) - -describe('sso check-domain response shape', () => { - it('does not expose internal org or provider identifiers to anonymous callers', async () => { - vi.resetModules() - rpcMock.mockImplementation((functionName: string) => { - if (functionName === 'get_sso_enforcement_by_domain') { - return Promise.resolve({ - data: [{ - enforce_sso: true, - org_id: '00000000-0000-4000-8000-000000000001', - provider_id: '00000000-0000-4000-8000-000000000002', - }], - error: null, - }) - } - - if (functionName === 'check_domain_sso') { - return Promise.resolve({ - data: [{ - has_sso: true, - org_id: '00000000-0000-4000-8000-000000000001', - provider_id: '00000000-0000-4000-8000-000000000003', - }], - error: null, - }) - } - - return Promise.resolve({ data: null, error: null }) - }) - - const { app } = await import('../supabase/functions/_backend/private/sso/check-domain.ts') - - const response = await app.request('http://sso.test/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'cf-connecting-ip': '203.0.113.10', - }, - body: JSON.stringify({ email: 'user@example.com' }), - }) - - expect(response.status).toBe(200) - await expect(response.json()).resolves.toEqual({ - has_sso: true, - enforce_sso: true, - }) - }) -}) diff --git a/tests/sso.test.ts b/tests/sso.test.ts index 8ddbf6a2f7..2e223e7b92 100644 --- a/tests/sso.test.ts +++ b/tests/sso.test.ts @@ -93,9 +93,7 @@ describe('[POST] /private/sso/check-domain', () => { try { const response = await fetchWithRetry(getEndpointUrl('/private/sso/check-domain'), { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: authHeaders, body: JSON.stringify({ email: `user@${expectedDomain.toUpperCase()}` }), }) @@ -103,11 +101,13 @@ describe('[POST] /private/sso/check-domain', () => { const data = await response.json() as { has_sso: boolean enforce_sso?: boolean + provider_id?: string + org_id?: string } expect(data.has_sso).toBe(true) expect(data.enforce_sso).toBe(false) - expect(data).not.toHaveProperty('provider_id') - expect(data).not.toHaveProperty('org_id') + expect(data.provider_id).toBeUndefined() + expect(data.org_id).toBeUndefined() const { data: rpcData, error: rpcError } = await (getSupabaseClient().rpc as any)('check_domain_sso', { p_domain: ` ${expectedDomain.toUpperCase()} ` }) expect(rpcError).toBeNull()