diff --git a/platform/packages/api/src/routes/deploy.ts b/platform/packages/api/src/routes/deploy.ts index dfd90c155..dbdfd1423 100644 --- a/platform/packages/api/src/routes/deploy.ts +++ b/platform/packages/api/src/routes/deploy.ts @@ -163,18 +163,19 @@ app.post('/:id/deploy/upload-urls', async (context) => { context.get('companyIdentity'), true, ); - const approved = Object.values(approval.data.artifact!.assetManifest); + // JSON preserves tuple boundaries and field types, including malformed + // approval metadata that must not match a valid upload through coercion. + function assetApprovalKey( + asset: Pick, + ): string { + return JSON.stringify([asset.blake3, asset.md5, asset.size]); + } + const approved = new Set( + Object.values(approval.data.artifact!.assetManifest).map(assetApprovalKey), + ); if ( approval.data.projectId !== projectId || - assets.some( - (asset) => - !approved.some( - (entry) => - entry.blake3 === asset.blake3 && - entry.md5 === asset.md5 && - entry.size === asset.size, - ), - ) + assets.some((asset) => !approved.has(assetApprovalKey(asset))) ) { throw new OAuthError( 'access_denied', diff --git a/platform/packages/api/test/integration/oauth.test.ts b/platform/packages/api/test/integration/oauth.test.ts index 42953f605..aeaa45bef 100644 --- a/platform/packages/api/test/integration/oauth.test.ts +++ b/platform/packages/api/test/integration/oauth.test.ts @@ -208,9 +208,24 @@ describe('registered OAuth applications', () => { size: 3, contentType: 'text/javascript', }; + const otherAsset = { + ...asset, + blake3: computeAssetHash(new TextEncoder().encode('page').buffer, '/page.js'), + md5: 'b'.repeat(32), + size: 4, + }; const prepared = createDeployArtifact({ ...artifact, - assetManifest: { '/app.js': { ...asset, hash: asset.blake3 } }, + assetManifest: { + '/app.js': { ...asset, hash: asset.blake3 }, + '/page.js': { ...otherAsset, hash: otherAsset.blake3 }, + '/wrong-type.js': { + ...asset, + blake3: 'd'.repeat(32), + hash: 'd'.repeat(32), + size: '3' as unknown as number, + }, + }, }); const issued = await request('/oauth/preview-deployment-requests', { method: 'POST', @@ -222,15 +237,19 @@ describe('registered OAuth applications', () => { }), }); const receipt = (await issued.json()) as { access_token: string; approval_id: string }; - const upload = (entry: typeof asset) => + const upload = (...assets: Array) => request('/projects/' + project.project_id + '/deploy/upload-urls', { method: 'POST', headers: { ...headers, Authorization: 'Bearer ' + receipt.access_token }, - body: JSON.stringify({ assets: [entry] }), + body: JSON.stringify({ assets }), }); expect((await upload(asset)).status).toBe(200); + expect((await upload(otherAsset, asset, asset)).status).toBe(200); expect((await upload({ ...asset, size: 4 })).status).toBe(403); + expect((await upload({ ...asset, md5: 'b'.repeat(32) })).status).toBe(403); expect((await upload({ ...asset, blake3: 'c'.repeat(32) })).status).toBe(403); + expect((await upload(otherAsset, { ...asset, size: 4 })).status).toBe(403); + expect((await upload({ ...asset, blake3: 'd'.repeat(32) })).status).toBe(403); const [record] = await env.db .select() .from(oauthRequests)