Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions supabase/functions/_backend/public/statistics/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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 })
Expand Down
53 changes: 52 additions & 1 deletion tests/statistics.test.ts
Original file line number Diff line number Diff line change
@@ -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))
Expand All @@ -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
Expand Down Expand Up @@ -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]
Expand Down
Loading