From f60fcb52d07acf012ce22dab58f1580017f00bea Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Mon, 16 Mar 2026 18:44:07 +0100 Subject: [PATCH 1/3] fix(api): prevent cross-org event broadcasts --- supabase/functions/_backend/private/events.ts | 20 +++++- tests/events.test.ts | 62 ++++++++++++++++++- 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/supabase/functions/_backend/private/events.ts b/supabase/functions/_backend/private/events.ts index 6ec890ec22..112918567b 100644 --- a/supabase/functions/_backend/private/events.ts +++ b/supabase/functions/_backend/private/events.ts @@ -1,4 +1,5 @@ import type { TrackOptions } from '@logsnag/node' +import type { Context } from 'hono' import type { MiddlewareKeyVariables } from '../utils/hono.ts' import { Hono } from 'hono/tiny' import { trackBentoEvent } from '../utils/bento.ts' @@ -7,17 +8,32 @@ import { middlewareV2 } from '../utils/hono_middleware.ts' import { logsnag } from '../utils/logsnag.ts' import { trackPosthogEvent } from '../utils/posthog.ts' import { broadcastCLIEvent } from '../utils/realtime_broadcast.ts' -import { supabaseWithAuth } from '../utils/supabase.ts' +import { hasOrgRight, hasOrgRightApikey, supabaseWithAuth } from '../utils/supabase.ts' import { backgroundTask } from '../utils/utils.ts' export const app = new Hono() app.use('/', useCors) +async function canAccessRequestedOrg(c: Context, orgId: string) { + const auth = c.get('auth') + if (!auth?.userId || !orgId) + return false + + if (auth.authType === 'apikey') + return hasOrgRightApikey(c, orgId, auth.userId, 'read', c.get('capgkey')) + + return hasOrgRight(c, orgId, auth.userId, 'read') +} + app.post('/', middlewareV2(['read', 'write', 'all', 'upload']), async (c) => { const body = await parseBody(c) + const requestedOrgId = typeof body.user_id === 'string' && body.user_id.length > 0 ? body.user_id : undefined + + if (requestedOrgId && !(await canAccessRequestedOrg(c, requestedOrgId))) + return c.json({ error: 'Forbidden' }, 403) - const orgId = body.user_id ?? c.get('auth')?.userId ?? '' + const orgId = requestedOrgId ?? c.get('auth')?.userId ?? '' // notifyConsole: broadcast to Supabase Realtime only, skip all tracking if (body.notifyConsole) { diff --git a/tests/events.test.ts b/tests/events.test.ts index fa4a882f21..1069ab0257 100644 --- a/tests/events.test.ts +++ b/tests/events.test.ts @@ -1,9 +1,10 @@ import { randomUUID } from 'node:crypto' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { APP_NAME, BASE_URL, headers, resetAndSeedAppData, resetAndSeedAppDataStats, resetAppData, resetAppDataStats } from './test-utils.ts' +import { APP_NAME, BASE_URL, getAuthHeaders, headers, ORG_ID, resetAndSeedAppData, resetAndSeedAppDataStats, resetAppData, resetAppDataStats } from './test-utils.ts' const id = randomUUID() const APPNAME_EVENT = `${APP_NAME}.e.${id}` +const FOREIGN_ORG_ID = randomUUID() beforeAll(async () => { await Promise.all([resetAndSeedAppData(APPNAME_EVENT), resetAndSeedAppDataStats(APPNAME_EVENT)]) @@ -38,6 +39,46 @@ describe('[POST] /private/events operations', () => { expect(data.status).toBe('ok') }) + it('rejects apikey attempts to broadcast events to a foreign org', async () => { + const response = await fetch(`${BASE_URL}/private/events`, { + method: 'POST', + headers: { + capgkey: headers.Authorization, + }, + body: JSON.stringify({ + channel: 'test', + event: 'test_event', + description: 'Testing event tracking', + notifyConsole: true, + user_id: FOREIGN_ORG_ID, + }), + }) + + const data = await response.json() as { error: string } + expect(response.status).toBe(403) + expect(data.error).toBe('Forbidden') + }) + + it('allows apikey broadcasts for an authorized org', async () => { + const response = await fetch(`${BASE_URL}/private/events`, { + method: 'POST', + headers: { + capgkey: headers.Authorization, + }, + body: JSON.stringify({ + channel: 'test', + event: 'test_event', + description: 'Testing event tracking', + notifyConsole: true, + user_id: ORG_ID, + }), + }) + + const data = await response.json() as { status: string } + expect(response.status).toBe(200) + expect(data.status).toBe('ok') + }) + // Skip JWT test as it requires auth infrastructure that may not be reliably available // The important test is that API key auth works, which is covered above it.skip('track event with authorization jwt', async () => { @@ -46,6 +87,25 @@ describe('[POST] /private/events operations', () => { // the main authentication path. }) + it('rejects jwt attempts to broadcast events to a foreign org', async () => { + const authHeaders = await getAuthHeaders() + const response = await fetch(`${BASE_URL}/private/events`, { + method: 'POST', + headers: authHeaders, + body: JSON.stringify({ + channel: 'test', + event: 'test_event', + description: 'Testing event tracking', + notifyConsole: true, + user_id: FOREIGN_ORG_ID, + }), + }) + + const data = await response.json() as { error: string } + expect(response.status).toBe(403) + expect(data.error).toBe('Forbidden') + }) + it('track event without authentication', async () => { const response = await fetch(`${BASE_URL}/private/events`, { method: 'POST', From b77036ec9398bea664061efa129cc0dc5d64a7db Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Tue, 17 Mar 2026 16:09:42 +0100 Subject: [PATCH 2/3] fix(events): keep user analytics ids working --- supabase/functions/_backend/private/events.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/supabase/functions/_backend/private/events.ts b/supabase/functions/_backend/private/events.ts index 112918567b..0ce377ccce 100644 --- a/supabase/functions/_backend/private/events.ts +++ b/supabase/functions/_backend/private/events.ts @@ -28,12 +28,16 @@ async function canAccessRequestedOrg(c: Context, orgId: app.post('/', middlewareV2(['read', 'write', 'all', 'upload']), async (c) => { const body = await parseBody(c) - const requestedOrgId = typeof body.user_id === 'string' && body.user_id.length > 0 ? body.user_id : undefined + const requestedOrgId = body.notifyConsole && typeof body.user_id === 'string' && body.user_id.length > 0 + ? body.user_id + : undefined if (requestedOrgId && !(await canAccessRequestedOrg(c, requestedOrgId))) return c.json({ error: 'Forbidden' }, 403) - const orgId = requestedOrgId ?? c.get('auth')?.userId ?? '' + const orgId = typeof body.user_id === 'string' && body.user_id.length > 0 + ? body.user_id + : c.get('auth')?.userId ?? '' // notifyConsole: broadcast to Supabase Realtime only, skip all tracking if (body.notifyConsole) { From 967481d3b2dd944086c7549973e73e050abff7f9 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Tue, 17 Mar 2026 17:07:32 +0100 Subject: [PATCH 3/3] test: cover jwt org event broadcasts --- tests/events.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/events.test.ts b/tests/events.test.ts index 1069ab0257..4213bd56ac 100644 --- a/tests/events.test.ts +++ b/tests/events.test.ts @@ -79,6 +79,25 @@ describe('[POST] /private/events operations', () => { expect(data.status).toBe('ok') }) + it('allows jwt broadcasts for an authorized org', async () => { + const authHeaders = await getAuthHeaders() + const response = await fetch(`${BASE_URL}/private/events`, { + method: 'POST', + headers: authHeaders, + body: JSON.stringify({ + channel: 'test', + event: 'test_event', + description: 'Testing event tracking', + notifyConsole: true, + user_id: ORG_ID, + }), + }) + + const data = await response.json() as { status: string } + expect(response.status).toBe(200) + expect(data.status).toBe('ok') + }) + // Skip JWT test as it requires auth infrastructure that may not be reliably available // The important test is that API key auth works, which is covered above it.skip('track event with authorization jwt', async () => {