diff --git a/supabase/functions/_backend/public/statistics/index.ts b/supabase/functions/_backend/public/statistics/index.ts index c173a9ff2c..ba0abc563f 100644 --- a/supabase/functions/_backend/public/statistics/index.ts +++ b/supabase/functions/_backend/public/statistics/index.ts @@ -153,6 +153,20 @@ function getAuthenticatedSupabase(c: Context, auth: AuthInfo) { return supabaseClient(c, authorization) } +function denyAppLimitedApiKeyOutsideScope(auth: AuthInfo, appId: string) { + if (auth.authType !== 'apikey') + return + + const limitedToApps = auth.apikey?.limited_to_apps + if (!Array.isArray(limitedToApps) || limitedToApps.length === 0) + return + + // Deny before app lookups so real sibling apps and missing apps are indistinguishable to scoped keys. + if (!limitedToApps.includes(appId)) { + throw quickError(401, 'no_access_to_app', 'No access to app', { data: auth.userId ?? null }) + } +} + function isRetryableStatsError(error: unknown) { return isRetryablePostgrestError(error) } @@ -756,6 +770,8 @@ app.get('/app/:app_id', async (c) => { const body = bodyParsed.data const auth = c.get('auth') as AuthInfo + denyAppLimitedApiKeyOutsideScope(auth, appId) + // Use unified RBAC permission check if (!await checkPermission(c, 'app.read', { appId })) { throw quickError(401, 'no_access_to_app', 'No access to app', { data: auth?.userId ?? null }) @@ -828,6 +844,8 @@ app.get('/app/:app_id/bundle_usage', async (c) => { const body = bodyParsed.data const auth = c.get('auth') as AuthInfo + denyAppLimitedApiKeyOutsideScope(auth, appId) + // Use unified RBAC permission check if (!await checkPermission(c, 'app.read', { appId })) { throw quickError(401, 'no_access_to_app', 'No access to app', { data: auth?.userId ?? null }) diff --git a/tests/statistics.test.ts b/tests/statistics.test.ts index 7b7b9072d4..f67fd2b0de 100644 --- a/tests/statistics.test.ts +++ b/tests/statistics.test.ts @@ -1,5 +1,6 @@ +import { randomUUID } from 'node:crypto' import { afterAll, describe, expect, it } from 'vitest' -import { APP_NAME_STATS, BASE_URL, getAuthHeadersForCredentials, getSupabaseClient, headersStats, ORG_ID_STATS } from './test-utils.ts' +import { APP_NAME_STATS, BASE_URL, getAuthHeadersForCredentials, getSupabaseClient, headersStats, ORG_ID_STATS, USER_ID_STATS } from './test-utils.ts' function hasSeededStats(statsData: unknown) { if (!Array.isArray(statsData)) @@ -21,6 +22,28 @@ async function deleteApikeyById(id: number) { .throwOnError() } +async function createStatsSiblingApp(appId: string) { + await getSupabaseClient() + .from('apps') + .insert({ + app_id: appId, + icon_url: '', + name: 'Stats Sibling App', + last_version: '1.0.0', + owner_org: ORG_ID_STATS, + user_id: USER_ID_STATS, + }) + .throwOnError() +} + +async function deleteAppByAppId(appId: string) { + await getSupabaseClient() + .from('apps') + .delete() + .eq('app_id', appId) + .throwOnError() +} + describe('[GET] /statistics operations with and without subkey', () => { const APPNAME = APP_NAME_STATS // Use the seeded stats app let subkeyId = 0 @@ -142,6 +165,34 @@ describe('[GET] /statistics operations with and without subkey', () => { expect(hasSeededStats(statsData)).toBe(true) }) + it('should not reveal sibling app existence with app-limited subkey', async () => { + expect(subkeyId).toBeGreaterThan(0) + + const subkeyHeaders = { 'x-limited-key-id': String(subkeyId) } + const fromDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] + const toDate = new Date().toISOString().split('T')[0] + const suffix = randomUUID().replaceAll('-', '') + const siblingApp = `com.stats.sibling.${suffix}` + const fakeApp = `com.stats.missing.${suffix}` + + await createStatsSiblingApp(siblingApp) + + try { + for (const appId of [siblingApp, fakeApp]) { + const getStats = await fetch(`${BASE_URL}/statistics/app/${appId}?from=${fromDate}&to=${toDate}`, { + method: 'GET', + headers: { ...headersStats, ...subkeyHeaders }, + }) + expect(getStats.status).toBe(401) + const statsData = await getStats.json<{ error: string }>() + expect(statsData.error).toBe('no_access_to_app') + } + } + finally { + await deleteAppByAppId(siblingApp) + } + }) + it('should get organization statistics with subkey', async () => { const subkeyHeaders = { 'x-limited-key-id': String(subkeyId) } const fromDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]