tests/build-upload-security.test.ts
Rama main · réplica de lectura del repositorio original.
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<typeof import('../supabase/functions/_backend/utils/pg_rls.ts')>()
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<string, unknown> = createDefaultBuildRequest()) => ({
lookup: vi.fn().mockResolvedValue({
data,
error: null,
}),
})
let queryBuilder: ReturnType<typeof createQueryBuilder>
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()
}
})
})