From ef097d2793ed4f734f325fb11928c8cdb0dea9a6 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Tue, 10 Feb 2026 12:59:10 +0000 Subject: [PATCH 1/2] fix(backend): prevent org existence oracle --- .../private/validate_password_compliance.ts | 66 ++++++++++--------- tests/password-policy.test.ts | 31 +++++++-- 2 files changed, 59 insertions(+), 38 deletions(-) diff --git a/supabase/functions/_backend/private/validate_password_compliance.ts b/supabase/functions/_backend/private/validate_password_compliance.ts index cf93f6e9f3..fe06db2c76 100644 --- a/supabase/functions/_backend/private/validate_password_compliance.ts +++ b/supabase/functions/_backend/private/validate_password_compliance.ts @@ -5,7 +5,7 @@ import { Hono } from 'hono/tiny' import { z } from 'zod/mini' import { parseBody, quickError, simpleError, useCors } from '../utils/hono.ts' import { cloudlog } from '../utils/logging.ts' -import { supabaseAdmin as useSupabaseAdmin } from '../utils/supabase.ts' +import { emptySupabase, supabaseClient, supabaseAdmin as useSupabaseAdmin } from '../utils/supabase.ts' interface ValidatePasswordCompliance { email: string @@ -92,17 +92,45 @@ app.post('/', async (c) => { const body = validationResult.data const { password: _password, ...bodyWithoutPassword } = body cloudlog({ requestId: c.get('requestId'), context: 'validate_password_compliance raw body', rawBody: bodyWithoutPassword }) - const supabaseAdmin = useSupabaseAdmin(c) + const adminClient = useSupabaseAdmin(c) - // Get the org's password policy - need admin for initial lookup - const { data: org, error: orgError } = await supabaseAdmin + // Authenticate first to avoid leaking org existence to unauthenticated callers. + const loginClient = emptySupabase(c) + const { data: signInData, error: signInError } = await loginClient.auth.signInWithPassword({ + email: body.email, + password: body.password, + }) + + if (signInError || !signInData.user || !signInData.session) { + cloudlog({ requestId: c.get('requestId'), context: 'validate_password_compliance - login failed', error: signInError?.message }) + return quickError(401, 'invalid_credentials', 'Invalid email or password') + } + + const userId = signInData.user.id + const userClient = supabaseClient(c, `Bearer ${signInData.session.access_token}`) + + // Check org membership/rights first. For non-members (and for non-existent orgs), + // this returns the same not_member response to avoid an existence oracle. + const orgAccess = await checkOrgReadAccess(userClient, body.org_id, c.get('requestId')) + if (orgAccess.error) { + return quickError(500, 'org_membership_lookup_failed', 'Failed to verify organization membership', { error: orgAccess.error }) + } + + if (!orgAccess.allowed) { + return quickError(403, 'not_member', 'You are not a member of this organization') + } + + // Fetch the org's password policy (member-only). + const { data: org, error: orgError } = await userClient .from('orgs') .select('id, password_policy_config') .eq('id', body.org_id) .single() if (orgError || !org) { - return quickError(404, 'org_not_found', 'Organization not found', { error: orgError?.message }) + // Do not return org_not_found here; keep the response stable for callers. + cloudlog({ requestId: c.get('requestId'), context: 'validate_password_compliance - org lookup failed', error: orgError?.message }) + return quickError(403, 'not_member', 'You are not a member of this organization') } // Check if org has password policy enabled @@ -118,30 +146,6 @@ app.post('/', async (c) => { return quickError(400, 'no_policy', 'Organization does not have a password policy enabled') } - // Attempt to sign in with the provided credentials to verify password - // Note: signInWithPassword needs admin to work without session - const supabase = useSupabaseAdmin(c) - const { data: signInData, error: signInError } = await supabase.auth.signInWithPassword({ - email: body.email, - password: body.password, - }) - - if (signInError || !signInData.user || !signInData.session) { - cloudlog({ requestId: c.get('requestId'), context: 'validate_password_compliance - login failed', error: signInError?.message }) - return quickError(401, 'invalid_credentials', 'Invalid email or password') - } - - const userId = signInData.user.id - - const orgAccess = await checkOrgReadAccess(supabase, body.org_id, c.get('requestId')) - if (orgAccess.error) { - return quickError(500, 'org_membership_lookup_failed', 'Failed to verify organization membership', { error: orgAccess.error }) - } - - if (!orgAccess.allowed) { - return quickError(403, 'not_member', 'You are not a member of this organization') - } - // Check if the password meets the policy requirements const policyCheck = passwordMeetsPolicy(body.password, policy) @@ -159,7 +163,7 @@ app.post('/', async (c) => { // Password is valid! Create or update the compliance record // Get the policy hash from the SQL function (matches the validation logic) - const { data: policyHash, error: hashError } = await supabase + const { data: policyHash, error: hashError } = await userClient .rpc('get_password_policy_hash', { policy_config: org.password_policy_config }) if (hashError || !policyHash) { @@ -168,7 +172,7 @@ app.post('/', async (c) => { } // Upsert the compliance record (service role bypasses RLS) - const { error: upsertError } = await supabaseAdmin + const { error: upsertError } = await adminClient .from('user_password_compliance') .upsert({ user_id: userId, diff --git a/tests/password-policy.test.ts b/tests/password-policy.test.ts index 8da278a9cb..f97c64a4eb 100644 --- a/tests/password-policy.test.ts +++ b/tests/password-policy.test.ts @@ -232,15 +232,15 @@ describe('[POST] /private/validate_password_compliance', () => { headers, method: 'POST', body: JSON.stringify({ - email: TEST_EMAIL, - password: 'TestPassword123!', + email: USER_EMAIL, + password: USER_PASSWORD, org_id: nonExistentOrgId, }), }) - expect(response.status).toBe(404) + expect(response.status).toBe(403) const responseData = await response.json() as { error: string } - expect(responseData.error).toBe('org_not_found') + expect(responseData.error).toBe('not_member') }) it('reject request for org without password policy', async () => { @@ -266,8 +266,8 @@ describe('[POST] /private/validate_password_compliance', () => { headers, method: 'POST', body: JSON.stringify({ - email: TEST_EMAIL, - password: 'TestPassword123!', + email: USER_EMAIL, + password: USER_PASSWORD, org_id: ORG_ID, }), }) @@ -292,7 +292,7 @@ describe('[POST] /private/validate_password_compliance', () => { headers, method: 'POST', body: JSON.stringify({ - email: TEST_EMAIL, + email: USER_EMAIL, password: 'WrongPassword123!', org_id: ORG_ID, }), @@ -303,6 +303,23 @@ describe('[POST] /private/validate_password_compliance', () => { expect(responseData.error).toBe('invalid_credentials') }) + it('does not reveal org existence when credentials are invalid', async () => { + const nonExistentOrgId = randomUUID() + const response = await fetch(`${BASE_URL}/private/validate_password_compliance`, { + headers, + method: 'POST', + body: JSON.stringify({ + email: USER_EMAIL, + password: 'WrongPassword123!', + org_id: nonExistentOrgId, + }), + }) + + expect(response.status).toBe(401) + const responseData = await response.json() as { error: string } + expect(responseData.error).toBe('invalid_credentials') + }) + it('reject request when user is not a member of the org', async () => { const nonMemberOrgId = randomUUID() const nonMemberCustomerId = `cus_pwd_nomember_${nonMemberOrgId}` From 7b0192f198f60da5f96006be0dd3d11b1246fbed Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Tue, 10 Feb 2026 13:11:20 +0000 Subject: [PATCH 2/2] fix(backend): avoid policy RLS loop --- .../private/validate_password_compliance.ts | 11 +++++++---- tests/password-policy.test.ts | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/supabase/functions/_backend/private/validate_password_compliance.ts b/supabase/functions/_backend/private/validate_password_compliance.ts index fe06db2c76..b0a89e0fa1 100644 --- a/supabase/functions/_backend/private/validate_password_compliance.ts +++ b/supabase/functions/_backend/private/validate_password_compliance.ts @@ -120,17 +120,20 @@ app.post('/', async (c) => { return quickError(403, 'not_member', 'You are not a member of this organization') } - // Fetch the org's password policy (member-only). - const { data: org, error: orgError } = await userClient + // Fetch the org's password policy after membership verification. + // + // IMPORTANT: do not use userClient here. orgs SELECT is guarded by check_min_rights, + // which enforces password-policy compliance, creating a circular dependency for users + // who are non-compliant (this endpoint is their remediation path). + const { data: org, error: orgError } = await adminClient .from('orgs') .select('id, password_policy_config') .eq('id', body.org_id) .single() if (orgError || !org) { - // Do not return org_not_found here; keep the response stable for callers. cloudlog({ requestId: c.get('requestId'), context: 'validate_password_compliance - org lookup failed', error: orgError?.message }) - return quickError(403, 'not_member', 'You are not a member of this organization') + return quickError(500, 'org_lookup_failed', 'Failed to load organization password policy', { error: orgError?.message }) } // Check if org has password policy enabled diff --git a/tests/password-policy.test.ts b/tests/password-policy.test.ts index f97c64a4eb..d684692bcc 100644 --- a/tests/password-policy.test.ts +++ b/tests/password-policy.test.ts @@ -243,6 +243,24 @@ describe('[POST] /private/validate_password_compliance', () => { expect(responseData.error).toBe('not_member') }) + it('returns policy errors for non-compliant member', async () => { + const response = await fetch(`${BASE_URL}/private/validate_password_compliance`, { + headers, + method: 'POST', + body: JSON.stringify({ + email: USER_EMAIL, + password: USER_PASSWORD, + org_id: ORG_ID, + }), + }) + + expect(response.status).toBe(400) + const responseData = await response.json() as { error: string, moreInfo?: { errors?: string[] } } + expect(responseData.error).toBe('password_does_not_meet_policy') + expect(Array.isArray(responseData.moreInfo?.errors)).toBe(true) + expect(responseData.moreInfo!.errors!.length).toBeGreaterThan(0) + }) + it('reject request for org without password policy', async () => { const policyConfig = { enabled: true,