import { HTTPException } from 'hono/http-exception' import { beforeEach, describe, expect, it, vi } from 'vitest' import { tusProxy } from '../supabase/functions/_backend/public/build/upload.ts' const { mockRlsQuery, mockCheckPermission, mockGetEnv } = vi.hoisted(() => ({ mockRlsQuery: vi.fn(), mockCheckPermission: vi.fn(), mockGetEnv: vi.fn(), })) vi.mock('../supabase/functions/_backend/utils/pg_rls.ts', async (importOriginal) => { const actual = await importOriginal() return { ...actual, withRlsContext: async (_c: unknown, _principal: unknown, run: (client: unknown) => unknown) => run({ query: mockRlsQuery }), } }) vi.mock('../supabase/functions/_backend/utils/rbac.ts', () => ({ checkPermission: mockCheckPermission, })) vi.mock('../supabase/functions/_backend/utils/utils.ts', () => ({ getEnv: mockGetEnv, })) describe('build upload proxy security', () => { const jobId = 'job-traversal-test' const appId = 'com.test.traversal.app' const orgId = 'org-traversal' const validUploadPath = `orgs/${orgId}/apps/${appId}/native-builds/file.zip` const jobUploadResource = validUploadPath.split('/').filter(Boolean).at(-1)! const futureExpiry = () => new Date(Date.now() + 60 * 60 * 1000).toISOString() const createDefaultBuildRequest = () => ({ app_id: appId, owner_org: orgId, builder_job_id: jobId, upload_path: validUploadPath, upload_expires_at: futureExpiry(), status: 'pending', }) // Each build_requests lookup under the API-key RLS context resolves the next `lookup` result. const createQueryBuilder = (data: Record = createDefaultBuildRequest()) => ({ lookup: vi.fn().mockResolvedValue({ data, error: null, }), }) let queryBuilder: ReturnType const fakeContext = (url: string, method = 'POST') => { const request = new Request(url, { method, headers: { 'Tus-Resumable': '1.0.0', }, }) return { req: { url: request.url, method: request.method, raw: request, header: (name: string) => request.headers.get(name), }, get: vi.fn().mockReturnValue('test-request-id'), } } beforeEach(() => { mockGetEnv.mockClear() mockCheckPermission.mockClear() mockRlsQuery.mockReset() queryBuilder = createQueryBuilder() mockRlsQuery.mockImplementation(async (text: string) => { expect(text).toContain('FROM public.build_requests WHERE builder_job_id = $1') const { data } = await queryBuilder.lookup() return { rows: data ? [{ row: data }] : [] } }) mockGetEnv.mockImplementation((_, key: string) => { if (key === 'BUILDER_URL') { return 'https://builder.capgo.app' } if (key === 'BUILDER_API_KEY') { return 'builder-secret' } return null }) mockCheckPermission.mockResolvedValue(true) }) it('rejects path traversal attempts before forwarding to builder', async () => { const context = fakeContext(`http://localhost/build/upload/${jobId}/%2e%2e%2Fjobs`, 'PATCH') let error: HTTPException | undefined try { await tusProxy(context as any, jobId, { user_id: 'user-test', key: 'api-test' } as any) } catch (err) { expect(err).toBeInstanceOf(HTTPException) if (!(err instanceof HTTPException)) { throw err } error = err } if (!error) { throw new Error('Expected tusProxy to reject with HTTPException') } expect(error.cause).toMatchObject({ error: 'invalid_path', message: 'Invalid upload path', }) }) it('rejects invalidly encoded paths before forwarding to builder', async () => { const context = fakeContext(`http://localhost/build/upload/${jobId}/%`, 'PATCH') let error: HTTPException | undefined try { await tusProxy(context as any, jobId, { user_id: 'user-test', key: 'api-test' } as any) } catch (err) { expect(err).toBeInstanceOf(HTTPException) if (!(err instanceof HTTPException)) { throw err } error = err } if (!error) { throw new Error('Expected tusProxy to reject with HTTPException') } expect(error.cause).toMatchObject({ error: 'invalid_path', message: 'Invalid upload path encoding.', }) }) it('does not reject canonical upload suffixes', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 201, headers: { Location: 'https://builder.capgo.app/upload/file.zip', }, })) try { const context = fakeContext(`http://localhost/build/upload/${jobId}/${jobUploadResource}`, 'PATCH') const response = await tusProxy(context as any, jobId, { user_id: 'user-test', key: 'api-test' } as any) expect(response.status).toBe(201) expect(fetchMock).toHaveBeenCalledWith( `https://builder.capgo.app/upload/${jobUploadResource}`, expect.anything(), ) } finally { fetchMock.mockRestore() } }) it('keeps TUS resume state discoverable between PATCH chunks', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch') .mockResolvedValueOnce(new Response(null, { status: 204, headers: { 'Upload-Offset': '4', 'Tus-Resumable': '1.0.0', }, })) .mockResolvedValueOnce(new Response(null, { status: 200, headers: { 'Upload-Offset': '4', 'Tus-Resumable': '1.0.0', }, })) .mockResolvedValueOnce(new Response(null, { status: 204, headers: { 'Upload-Offset': '5', 'Tus-Resumable': '1.0.0', }, })) try { const uploadUrl = `http://localhost/build/upload/${jobId}/${jobUploadResource}` const apikey = { user_id: 'user-test', key: 'api-test' } as any const firstPatchResponse = await tusProxy(fakeContext(uploadUrl, 'PATCH') as any, jobId, apikey) const headResponse = await tusProxy(fakeContext(uploadUrl, 'HEAD') as any, jobId, apikey, 'HEAD') const secondPatchResponse = await tusProxy(fakeContext(uploadUrl, 'PATCH') as any, jobId, apikey) const forwardedMethods = fetchMock.mock.calls.map(([, init]) => (init as RequestInit).method) const [, headInit] = fetchMock.mock.calls[1] as [string, RequestInit] expect(firstPatchResponse.status).toBe(204) expect(firstPatchResponse.headers.get('Upload-Offset')).toBe('4') expect(headResponse.status).toBe(200) expect(headResponse.headers.get('Upload-Offset')).toBe('4') expect(secondPatchResponse.status).toBe(204) expect(secondPatchResponse.headers.get('Upload-Offset')).toBe('5') expect(forwardedMethods).toEqual(['PATCH', 'HEAD', 'PATCH']) expect(headInit).not.toHaveProperty('body') expect(headInit).not.toHaveProperty('duplex') } finally { fetchMock.mockRestore() } }) it('forwards HEAD probes to builder without a request body', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 204, headers: { 'Upload-Offset': '5', 'Tus-Resumable': '1.0.0', }, })) try { const context = fakeContext(`http://localhost/build/upload/${jobId}/${jobUploadResource}`, 'HEAD') const response = await tusProxy(context as any, jobId, { user_id: 'user-test', key: 'api-test' } as any, 'HEAD') const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] expect(response.status).toBe(204) expect(response.headers.get('Upload-Offset')).toBe('5') expect(init.method).toBe('HEAD') expect(init).not.toHaveProperty('body') expect(init).not.toHaveProperty('duplex') } finally { fetchMock.mockRestore() } }) it('rejects PATCH to a different TUS suffix than this job upload_path', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 204 })) let error: HTTPException | undefined try { await tusProxy( fakeContext(`http://localhost/build/upload/${jobId}/other-job-id`, 'PATCH') as any, jobId, { user_id: 'user-test', key: 'api-test' } as any, ) } catch (err) { expect(err).toBeInstanceOf(HTTPException) if (!(err instanceof HTTPException)) { throw err } error = err } finally { const forwardedUrls = fetchMock.mock.calls.map(([url]) => String(url)) expect(forwardedUrls.some(url => url.includes('/upload/other-job-id'))).toBe(false) expect(fetchMock).not.toHaveBeenCalled() fetchMock.mockRestore() } if (!error) { throw new Error('Expected tusProxy to reject with HTTPException') } expect(error.status).toBe(400) expect(error.cause).toMatchObject({ error: 'invalid_path', message: 'Upload path does not match this build request', }) }) it('rejects when upload window closes between initial validation and builder forward', async () => { const defaultRequest = createDefaultBuildRequest() queryBuilder = { lookup: vi.fn() .mockResolvedValueOnce({ data: defaultRequest, error: null }) .mockResolvedValueOnce({ data: { status: 'running', upload_expires_at: defaultRequest.upload_expires_at, }, error: null, }), } const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 204 })) let error: HTTPException | undefined try { await tusProxy( fakeContext(`http://localhost/build/upload/${jobId}/${jobUploadResource}`, 'PATCH') as any, jobId, { user_id: 'user-test', key: 'api-test' } as any, ) } catch (err) { expect(err).toBeInstanceOf(HTTPException) if (!(err instanceof HTTPException)) { throw err } error = err } finally { expect(fetchMock).not.toHaveBeenCalled() fetchMock.mockRestore() } if (!error) { throw new Error('Expected tusProxy to reject with HTTPException') } expect(error.status).toBe(403) expect(error.cause).toMatchObject({ error: 'upload_not_allowed', message: 'Upload is not allowed for this build status', }) expect(queryBuilder.lookup).toHaveBeenCalledTimes(2) }) it('rejects when upload expires between initial validation and builder forward', async () => { const defaultRequest = createDefaultBuildRequest() queryBuilder = { lookup: vi.fn() .mockResolvedValueOnce({ data: defaultRequest, error: null }) .mockResolvedValueOnce({ data: { status: 'pending', upload_expires_at: new Date(Date.now() - 60 * 1000).toISOString(), }, error: null, }), } const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 204 })) let error: HTTPException | undefined try { await tusProxy( fakeContext(`http://localhost/build/upload/${jobId}/${jobUploadResource}`, 'PATCH') as any, jobId, { user_id: 'user-test', key: 'api-test' } as any, ) } catch (err) { expect(err).toBeInstanceOf(HTTPException) if (!(err instanceof HTTPException)) { throw err } error = err } finally { expect(fetchMock).not.toHaveBeenCalled() fetchMock.mockRestore() } if (!error) { throw new Error('Expected tusProxy to reject with HTTPException') } expect(error.status).toBe(403) expect(error.cause).toMatchObject({ error: 'upload_expired', message: 'Upload window has expired', }) expect(queryBuilder.lookup).toHaveBeenCalledTimes(2) }) it('rejects expired upload_expires_at before forwarding', async () => { queryBuilder = createQueryBuilder({ ...createDefaultBuildRequest(), upload_expires_at: new Date(Date.now() - 60 * 1000).toISOString(), }) const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 204 })) let error: HTTPException | undefined try { await tusProxy( fakeContext(`http://localhost/build/upload/${jobId}/${jobUploadResource}`, 'PATCH') as any, jobId, { user_id: 'user-test', key: 'api-test' } as any, ) } catch (err) { expect(err).toBeInstanceOf(HTTPException) if (!(err instanceof HTTPException)) { throw err } error = err } finally { expect(fetchMock).not.toHaveBeenCalled() fetchMock.mockRestore() } if (!error) { throw new Error('Expected tusProxy to reject with HTTPException') } expect(error.status).toBe(403) expect(error.cause).toMatchObject({ error: 'upload_expired', message: 'Upload window has expired', }) }) it('rejects non-pending terminal status before forwarding', async () => { queryBuilder = createQueryBuilder({ ...createDefaultBuildRequest(), status: 'failed', }) const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 204 })) let error: HTTPException | undefined try { await tusProxy( fakeContext(`http://localhost/build/upload/${jobId}/${jobUploadResource}`, 'PATCH') as any, jobId, { user_id: 'user-test', key: 'api-test' } as any, ) } catch (err) { expect(err).toBeInstanceOf(HTTPException) if (!(err instanceof HTTPException)) { throw err } error = err } finally { expect(fetchMock).not.toHaveBeenCalled() fetchMock.mockRestore() } if (!error) { throw new Error('Expected tusProxy to reject with HTTPException') } expect(error.status).toBe(403) expect(error.cause).toMatchObject({ error: 'upload_not_allowed', message: 'Upload is not allowed for this build status', }) }) it('rejects POST with extra TUS suffix before forwarding', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 201 })) let error: HTTPException | undefined try { await tusProxy( fakeContext(`http://localhost/build/upload/${jobId}/some-id`, 'POST') as any, jobId, { user_id: 'user-test', key: 'api-test' } as any, ) } catch (err) { expect(err).toBeInstanceOf(HTTPException) if (!(err instanceof HTTPException)) { throw err } error = err } finally { expect(fetchMock).not.toHaveBeenCalled() fetchMock.mockRestore() } if (!error) { throw new Error('Expected tusProxy to reject with HTTPException') } expect(error.status).toBe(400) expect(error.cause).toMatchObject({ error: 'invalid_path', message: 'Upload create path must not include a TUS resource id.', }) }) it('forwards valid POST without extra suffix to builder upload root', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 201, headers: { Location: 'https://builder.capgo.app/upload/file.zip', }, })) try { const context = fakeContext(`http://localhost/build/upload/${jobId}`, 'POST') const response = await tusProxy(context as any, jobId, { user_id: 'user-test', key: 'api-test' } as any) expect(response.status).toBe(201) expect(fetchMock).toHaveBeenCalledWith( 'https://builder.capgo.app/upload/', expect.anything(), ) } finally { fetchMock.mockRestore() } }) })