From 2d6379994ce4eff6289a98370ceba8a97a0569b1 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Tue, 24 Feb 2026 22:08:58 +0000 Subject: [PATCH 1/2] fix(backend): bind build start/cancel to job owner --- .../functions/_backend/public/build/cancel.ts | 34 ++++++------- .../functions/_backend/public/build/index.ts | 12 +---- .../functions/_backend/public/build/start.ts | 49 +++++++------------ 3 files changed, 38 insertions(+), 57 deletions(-) diff --git a/supabase/functions/_backend/public/build/cancel.ts b/supabase/functions/_backend/public/build/cancel.ts index e3a246a780..b4b19c02e4 100644 --- a/supabase/functions/_backend/public/build/cancel.ts +++ b/supabase/functions/_backend/public/build/cancel.ts @@ -1,6 +1,6 @@ import type { Context } from 'hono' import type { Database } from '../../utils/supabase.types.ts' -import { simpleError } from '../../utils/hono.ts' +import { quickError, simpleError } from '../../utils/hono.ts' import { cloudlog, cloudlogErr } from '../../utils/logging.ts' import { checkPermission } from '../../utils/rbac.ts' import { supabaseApikey } from '../../utils/supabase.ts' @@ -9,19 +9,9 @@ import { getEnv } from '../../utils/utils.ts' export async function cancelBuild( c: Context, jobId: string, - appId: string, apikey: Database['public']['Tables']['apikeys']['Row'], ): Promise { - cloudlog({ - requestId: c.get('requestId'), - message: 'Cancel build request', - job_id: jobId, - app_id: appId, - user_id: apikey.user_id, - }) - - // Bind jobId to appId under RLS before calling the builder. - // This prevents cross-app access by mixing an allowed app_id with another app's jobId. + // Bind jobId to its request owner before calling the builder. const supabase = supabaseApikey(c, apikey.key) const { data: buildRequest, error: buildRequestError } = await supabase .from('build_requests') @@ -39,17 +29,27 @@ export async function cancelBuild( throw simpleError('internal_error', 'Failed to fetch build request') } - if (!buildRequest || buildRequest.app_id !== appId) { - throw simpleError('unauthorized', 'You do not have permission to cancel builds for this app') + if (!buildRequest) { + throw quickError(404, 'build_request_not_found', 'Build request not found') } + const boundAppId = buildRequest.app_id + + cloudlog({ + requestId: c.get('requestId'), + message: 'Cancel build request', + job_id: jobId, + app_id: boundAppId, + user_id: apikey.user_id, + }) + // Security: Check if user has permission to manage builds (auth context set by middlewareKey) - if (!(await checkPermission(c, 'app.build_native', { appId: buildRequest.app_id }))) { + if (!(await checkPermission(c, 'app.build_native', { appId: boundAppId }))) { cloudlogErr({ requestId: c.get('requestId'), message: 'Unauthorized cancel build', job_id: jobId, - app_id: appId, + app_id: boundAppId, user_id: apikey.user_id, }) throw simpleError('unauthorized', 'You do not have permission to cancel builds for this app') @@ -93,7 +93,7 @@ export async function cancelBuild( updated_at: new Date().toISOString(), }) .eq('builder_job_id', jobId) - .eq('app_id', buildRequest.app_id) + .eq('app_id', boundAppId) if (updateError) { cloudlogErr({ diff --git a/supabase/functions/_backend/public/build/index.ts b/supabase/functions/_backend/public/build/index.ts index 06faa0a34d..57d31fc38b 100644 --- a/supabase/functions/_backend/public/build/index.ts +++ b/supabase/functions/_backend/public/build/index.ts @@ -31,12 +31,8 @@ app.post('/request', middlewareKey(['all', 'write']), async (c) => { // POST /build/start/:jobId - Start a build after uploading bundle app.post('/start/:jobId', middlewareKey(['all', 'write']), async (c) => { const jobId = c.req.param('jobId') - const body = await getBodyOrQuery<{ app_id: string }>(c) - if (!body.app_id) { - throw new Error('app_id is required in request body') - } const apikey = c.get('apikey') as Database['public']['Tables']['apikeys']['Row'] - return startBuild(c, jobId, body.app_id, apikey) + return startBuild(c, jobId, apikey) }) // GET /build/status - Get build status and record billing @@ -60,12 +56,8 @@ app.get('/logs/:jobId', middlewareKey(['all', 'read']), async (c) => { // POST /build/cancel/:jobId - Cancel a running build app.post('/cancel/:jobId', middlewareKey(['all', 'write']), async (c) => { const jobId = c.req.param('jobId') - const body = await getBodyOrQuery<{ app_id: string }>(c) - if (!body.app_id) { - throw new Error('app_id is required in request body') - } const apikey = c.get('apikey') as Database['public']['Tables']['apikeys']['Row'] - return cancelBuild(c, jobId, body.app_id, apikey) + return cancelBuild(c, jobId, apikey) }) function tusOptionsResponse() { diff --git a/supabase/functions/_backend/public/build/start.ts b/supabase/functions/_backend/public/build/start.ts index 098ef13cc5..6c63942ee4 100644 --- a/supabase/functions/_backend/public/build/start.ts +++ b/supabase/functions/_backend/public/build/start.ts @@ -1,7 +1,8 @@ import type { Context } from 'hono' import type { Database } from '../../utils/supabase.types.ts' +import { HTTPException } from 'hono/http-exception' import { SignJWT } from 'jose' -import { simpleError } from '../../utils/hono.ts' +import { quickError, simpleError } from '../../utils/hono.ts' import { cloudlog, cloudlogErr } from '../../utils/logging.ts' import { checkPermission } from '../../utils/rbac.ts' import { supabaseApikey } from '../../utils/supabase.ts' @@ -76,28 +77,18 @@ async function markBuildAsFailed( export async function startBuild( c: Context, jobId: string, - appId: string, apikey: Database['public']['Tables']['apikeys']['Row'], ): Promise { let alreadyMarkedAsFailed = false const apikeyKey = apikey.key ?? c.get('capgkey') ?? apikey.key_hash ?? null try { - cloudlog({ - requestId: c.get('requestId'), - message: 'Start build request', - job_id: jobId, - app_id: appId, - user_id: apikey.user_id, - }) - if (!apikeyKey) { const errorMsg = 'No API key available to start build' cloudlogErr({ requestId: c.get('requestId'), message: 'Missing API key for start build', job_id: jobId, - app_id: appId, user_id: apikey.user_id, }) throw simpleError('not_authorized', errorMsg) @@ -122,32 +113,30 @@ export async function startBuild( throw simpleError('internal_error', 'Failed to fetch build request') } - if (!buildRequest || buildRequest.app_id !== appId) { - const errorMsg = 'You do not have permission to start builds for this app' - cloudlogErr({ - requestId: c.get('requestId'), - message: 'Unauthorized start build (job/app mismatch or not accessible)', - job_id: jobId, - app_id: appId, - user_id: apikey.user_id, - }) - await markBuildAsFailed(c, jobId, errorMsg, apikeyKey) - alreadyMarkedAsFailed = true - throw simpleError('unauthorized', errorMsg) + if (!buildRequest) { + throw quickError(404, 'build_request_not_found', 'Build request not found') } + const boundAppId = buildRequest.app_id + + cloudlog({ + requestId: c.get('requestId'), + message: 'Start build request', + job_id: jobId, + app_id: boundAppId, + user_id: apikey.user_id, + }) + // Security: Check if user has permission to manage builds (auth context set by middlewareKey) - if (!(await checkPermission(c, 'app.build_native', { appId: buildRequest.app_id }))) { + if (!(await checkPermission(c, 'app.build_native', { appId: boundAppId }))) { const errorMsg = 'You do not have permission to start builds for this app' cloudlogErr({ requestId: c.get('requestId'), message: 'Unauthorized start build', job_id: jobId, - app_id: appId, + app_id: boundAppId, user_id: apikey.user_id, }) - await markBuildAsFailed(c, jobId, errorMsg, apikeyKey) - alreadyMarkedAsFailed = true throw simpleError('unauthorized', errorMsg) } @@ -194,7 +183,7 @@ export async function startBuild( updated_at: new Date().toISOString(), }) .eq('builder_job_id', jobId) - .eq('app_id', buildRequest.app_id) + .eq('app_id', boundAppId) if (updateError) { cloudlogErr({ @@ -221,7 +210,7 @@ export async function startBuild( // - Authorize access to live build logs for the given jobId/appId/user // - Stream logs directly to the CLI without going through this API as a proxy // If the direct URL and token are not provided, the CLI fails to get the logs of the build. - logsToken = await generateLogStreamToken(jobId, apikey.user_id, appId, jwtSecret) + logsToken = await generateLogStreamToken(jobId, apikey.user_id, boundAppId, jwtSecret) logsUrl = `${publicUrl}/build_logs_direct/${jobId}` cloudlog({ @@ -257,7 +246,7 @@ export async function startBuild( } catch (error) { // Mark build as failed for any unexpected error (but only if not already marked) - if (!alreadyMarkedAsFailed && apikeyKey) { + if (!alreadyMarkedAsFailed && apikeyKey && !(error instanceof HTTPException)) { const errorMsg = error instanceof Error ? error.message : String(error) await markBuildAsFailed(c, jobId, errorMsg, apikeyKey) } From e959d0b01071ae5b86e3c326b376caa756ef4d9b Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 25 Feb 2026 05:39:17 +0000 Subject: [PATCH 2/2] fix(backend): preserve build scope contract while binding app to jobId --- .../functions/_backend/public/build/cancel.ts | 44 +++++++++++-------- .../functions/_backend/public/build/index.ts | 12 ++++- .../functions/_backend/public/build/start.ts | 44 ++++++++++++------- 3 files changed, 64 insertions(+), 36 deletions(-) diff --git a/supabase/functions/_backend/public/build/cancel.ts b/supabase/functions/_backend/public/build/cancel.ts index b4b19c02e4..4589d12ec8 100644 --- a/supabase/functions/_backend/public/build/cancel.ts +++ b/supabase/functions/_backend/public/build/cancel.ts @@ -1,6 +1,6 @@ import type { Context } from 'hono' import type { Database } from '../../utils/supabase.types.ts' -import { quickError, simpleError } from '../../utils/hono.ts' +import { simpleError } from '../../utils/hono.ts' import { cloudlog, cloudlogErr } from '../../utils/logging.ts' import { checkPermission } from '../../utils/rbac.ts' import { supabaseApikey } from '../../utils/supabase.ts' @@ -9,14 +9,28 @@ import { getEnv } from '../../utils/utils.ts' export async function cancelBuild( c: Context, jobId: string, + appId: string, apikey: Database['public']['Tables']['apikeys']['Row'], ): Promise { + if (!(await checkPermission(c, 'app.build_native', { appId }))) { + const errorMsg = 'You do not have permission to cancel builds for this app' + cloudlogErr({ + requestId: c.get('requestId'), + message: 'Unauthorized cancel build', + job_id: jobId, + app_id: appId, + user_id: apikey.user_id, + }) + throw simpleError('unauthorized', errorMsg) + } + // Bind jobId to its request owner before calling the builder. const supabase = supabaseApikey(c, apikey.key) const { data: buildRequest, error: buildRequestError } = await supabase .from('build_requests') .select('app_id') .eq('builder_job_id', jobId) + .eq('app_id', appId) .maybeSingle() if (buildRequestError) { @@ -30,31 +44,25 @@ export async function cancelBuild( } if (!buildRequest) { - throw quickError(404, 'build_request_not_found', 'Build request not found') + const errorMsg = 'You do not have permission to cancel builds for this app' + cloudlogErr({ + requestId: c.get('requestId'), + message: 'Unauthorized cancel build (job/app mismatch or missing)', + job_id: jobId, + app_id: appId, + user_id: apikey.user_id, + }) + throw simpleError('unauthorized', errorMsg) } - const boundAppId = buildRequest.app_id - cloudlog({ requestId: c.get('requestId'), message: 'Cancel build request', job_id: jobId, - app_id: boundAppId, + app_id: appId, user_id: apikey.user_id, }) - // Security: Check if user has permission to manage builds (auth context set by middlewareKey) - if (!(await checkPermission(c, 'app.build_native', { appId: boundAppId }))) { - cloudlogErr({ - requestId: c.get('requestId'), - message: 'Unauthorized cancel build', - job_id: jobId, - app_id: boundAppId, - user_id: apikey.user_id, - }) - throw simpleError('unauthorized', 'You do not have permission to cancel builds for this app') - } - // Call builder to cancel the job const builderResponse = await fetch(`${getEnv(c, 'BUILDER_URL')}/jobs/${jobId}/cancel`, { method: 'POST', @@ -93,7 +101,7 @@ export async function cancelBuild( updated_at: new Date().toISOString(), }) .eq('builder_job_id', jobId) - .eq('app_id', boundAppId) + .eq('app_id', appId) if (updateError) { cloudlogErr({ diff --git a/supabase/functions/_backend/public/build/index.ts b/supabase/functions/_backend/public/build/index.ts index 57d31fc38b..06faa0a34d 100644 --- a/supabase/functions/_backend/public/build/index.ts +++ b/supabase/functions/_backend/public/build/index.ts @@ -31,8 +31,12 @@ app.post('/request', middlewareKey(['all', 'write']), async (c) => { // POST /build/start/:jobId - Start a build after uploading bundle app.post('/start/:jobId', middlewareKey(['all', 'write']), async (c) => { const jobId = c.req.param('jobId') + const body = await getBodyOrQuery<{ app_id: string }>(c) + if (!body.app_id) { + throw new Error('app_id is required in request body') + } const apikey = c.get('apikey') as Database['public']['Tables']['apikeys']['Row'] - return startBuild(c, jobId, apikey) + return startBuild(c, jobId, body.app_id, apikey) }) // GET /build/status - Get build status and record billing @@ -56,8 +60,12 @@ app.get('/logs/:jobId', middlewareKey(['all', 'read']), async (c) => { // POST /build/cancel/:jobId - Cancel a running build app.post('/cancel/:jobId', middlewareKey(['all', 'write']), async (c) => { const jobId = c.req.param('jobId') + const body = await getBodyOrQuery<{ app_id: string }>(c) + if (!body.app_id) { + throw new Error('app_id is required in request body') + } const apikey = c.get('apikey') as Database['public']['Tables']['apikeys']['Row'] - return cancelBuild(c, jobId, apikey) + return cancelBuild(c, jobId, body.app_id, apikey) }) function tusOptionsResponse() { diff --git a/supabase/functions/_backend/public/build/start.ts b/supabase/functions/_backend/public/build/start.ts index 6c63942ee4..a84346d986 100644 --- a/supabase/functions/_backend/public/build/start.ts +++ b/supabase/functions/_backend/public/build/start.ts @@ -2,7 +2,7 @@ import type { Context } from 'hono' import type { Database } from '../../utils/supabase.types.ts' import { HTTPException } from 'hono/http-exception' import { SignJWT } from 'jose' -import { quickError, simpleError } from '../../utils/hono.ts' +import { simpleError } from '../../utils/hono.ts' import { cloudlog, cloudlogErr } from '../../utils/logging.ts' import { checkPermission } from '../../utils/rbac.ts' import { supabaseApikey } from '../../utils/supabase.ts' @@ -77,6 +77,7 @@ async function markBuildAsFailed( export async function startBuild( c: Context, jobId: string, + appId: string, apikey: Database['public']['Tables']['apikeys']['Row'], ): Promise { let alreadyMarkedAsFailed = false @@ -97,10 +98,26 @@ export async function startBuild( // Bind jobId to appId under RLS before calling the builder. // This prevents cross-app access by mixing an allowed app_id with another app's jobId. const supabase = supabaseApikey(c, apikeyKey) + + // Security: Check if user has permission to manage builds for the supplied app + // before validating builder job ownership. + if (!(await checkPermission(c, 'app.build_native', { appId }))) { + const errorMsg = 'You do not have permission to start builds for this app' + cloudlogErr({ + requestId: c.get('requestId'), + message: 'Unauthorized start build', + job_id: jobId, + app_id: appId, + user_id: apikey.user_id, + }) + throw simpleError('unauthorized', errorMsg) + } + const { data: buildRequest, error: buildRequestError } = await supabase .from('build_requests') .select('app_id') .eq('builder_job_id', jobId) + .eq('app_id', appId) .maybeSingle() if (buildRequestError) { @@ -114,10 +131,18 @@ export async function startBuild( } if (!buildRequest) { - throw quickError(404, 'build_request_not_found', 'Build request not found') + const errorMsg = 'You do not have permission to start builds for this app' + cloudlogErr({ + requestId: c.get('requestId'), + message: 'Unauthorized start build (job/app mismatch or missing)', + job_id: jobId, + app_id: appId, + user_id: apikey.user_id, + }) + throw simpleError('unauthorized', errorMsg) } - const boundAppId = buildRequest.app_id + const boundAppId = appId cloudlog({ requestId: c.get('requestId'), @@ -127,19 +152,6 @@ export async function startBuild( user_id: apikey.user_id, }) - // Security: Check if user has permission to manage builds (auth context set by middlewareKey) - if (!(await checkPermission(c, 'app.build_native', { appId: boundAppId }))) { - const errorMsg = 'You do not have permission to start builds for this app' - cloudlogErr({ - requestId: c.get('requestId'), - message: 'Unauthorized start build', - job_id: jobId, - app_id: boundAppId, - user_id: apikey.user_id, - }) - throw simpleError('unauthorized', errorMsg) - } - // Call builder to start the job const builderResponse = await fetch(`${getEnv(c, 'BUILDER_URL')}/jobs/${jobId}/start`, { method: 'POST',