From 6e593581daafbff2fb667d91a60e3e3cff135446 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 30 Jul 2026 22:09:26 -0700 Subject: [PATCH 1/2] fix(uploads): set Content-Type once on presigned PUTs; document x-goog-meta-folderid in the GCS CORS example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XMLHttpRequest.setRequestHeader appends on repeated calls (values join with a comma), and GCS is the only provider whose signed uploadHeaders include Content-Type — so single-shot GCS uploads sent 'x, x', which fails V4 signature verification with 403 (headers canonicalize to a comma-separated value that must match what was signed; multipart part PUTs are unaffected since part URLs don't sign Content-Type). The client now sets its default Content-Type only when the server's signed headers don't already carry one, with regression tests for both paths. Also adds x-goog-meta-folderid to the documented GCS CORS responseHeader list — workspace uploads now sign a folderId metadata header, and GCS CORS matches preflight request headers against that list exactly (no wildcards), so the missing entry blocked browser uploads into folders. --- .../platform/self-hosting/object-storage.mdx | 1 + .../lib/uploads/client/direct-upload.test.ts | 41 +++++++++++++++++++ apps/sim/lib/uploads/client/direct-upload.ts | 10 ++++- 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx b/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx index 5a15538a0a5..b0dd2d1a03f 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx @@ -245,6 +245,7 @@ cat > /tmp/cors.json <<'EOF' "x-goog-meta-purpose", "x-goog-meta-userid", "x-goog-meta-workspaceid", + "x-goog-meta-folderid", "x-goog-meta-workflowid", "x-goog-meta-executionid" ], diff --git a/apps/sim/lib/uploads/client/direct-upload.test.ts b/apps/sim/lib/uploads/client/direct-upload.test.ts index 26c8bb99a1e..da2b2aca65f 100644 --- a/apps/sim/lib/uploads/client/direct-upload.test.ts +++ b/apps/sim/lib/uploads/client/direct-upload.test.ts @@ -82,6 +82,47 @@ describe('runUploadStrategy', () => { expect(MockXHR.instances[0].open).toHaveBeenCalledWith('PUT', 'https://s3/presigned') }) + it('sets Content-Type exactly once when uploadHeaders already carry it (GCS signed uploads)', async () => { + const file = makeFile(1024) + + await runUploadStrategy({ + file, + workspaceId: 'ws-1', + context: 'workspace', + presignedOverride: presigned({ + uploadHeaders: { + 'Content-Type': 'application/octet-stream', + 'x-goog-meta-workspaceid': 'ws-1', + }, + }), + }) + + const calls = MockXHR.instances[0].setRequestHeader.mock.calls + const contentTypeCalls = calls.filter( + ([k]: [string, string]) => k.toLowerCase() === 'content-type' + ) + expect(contentTypeCalls).toHaveLength(1) + expect(contentTypeCalls[0][1]).toBe('application/octet-stream') + expect(calls.some(([k]: [string, string]) => k === 'x-goog-meta-workspaceid')).toBe(true) + }) + + it('falls back to the file content type when uploadHeaders omit Content-Type', async () => { + const file = makeFile(1024) + + await runUploadStrategy({ + file, + workspaceId: 'ws-1', + context: 'workspace', + presignedOverride: presigned({ uploadHeaders: { 'x-ms-blob-type': 'BlockBlob' } }), + }) + + const calls = MockXHR.instances[0].setRequestHeader.mock.calls + const contentTypeCalls = calls.filter( + ([k]: [string, string]) => k.toLowerCase() === 'content-type' + ) + expect(contentTypeCalls).toHaveLength(1) + }) + it('throws FALLBACK_REQUIRED when server signals no cloud storage', async () => { const file = makeFile(ONE_MB) diff --git a/apps/sim/lib/uploads/client/direct-upload.ts b/apps/sim/lib/uploads/client/direct-upload.ts index ad448f891e3..5583171f82e 100644 --- a/apps/sim/lib/uploads/client/direct-upload.ts +++ b/apps/sim/lib/uploads/client/direct-upload.ts @@ -292,7 +292,15 @@ const uploadViaPresignedPut = (opts: UploadViaPutOptions): Promise => { }) xhr.open('PUT', presignedUrl) - xhr.setRequestHeader('Content-Type', getFileContentType(file)) + // XHR appends on repeated setRequestHeader calls, so when the server's + // signed headers already carry Content-Type (GCS), setting it here too + // produces a doubled value ("x, x") that breaks the URL signature. + const providesContentType = + uploadHeaders && + Object.keys(uploadHeaders).some((key) => key.toLowerCase() === 'content-type') + if (!providesContentType) { + xhr.setRequestHeader('Content-Type', getFileContentType(file)) + } if (uploadHeaders) { for (const [key, value] of Object.entries(uploadHeaders)) { xhr.setRequestHeader(key, value) From 3a62851d30c5ad0bd956e634cbfad3b160352630 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 30 Jul 2026 22:16:31 -0700 Subject: [PATCH 2/2] chore(uploads): drop inline comment --- apps/sim/lib/uploads/client/direct-upload.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/sim/lib/uploads/client/direct-upload.ts b/apps/sim/lib/uploads/client/direct-upload.ts index 5583171f82e..41e9911c26d 100644 --- a/apps/sim/lib/uploads/client/direct-upload.ts +++ b/apps/sim/lib/uploads/client/direct-upload.ts @@ -292,9 +292,6 @@ const uploadViaPresignedPut = (opts: UploadViaPutOptions): Promise => { }) xhr.open('PUT', presignedUrl) - // XHR appends on repeated setRequestHeader calls, so when the server's - // signed headers already carry Content-Type (GCS), setting it here too - // produces a doubled value ("x, x") that breaks the URL signature. const providesContentType = uploadHeaders && Object.keys(uploadHeaders).some((key) => key.toLowerCase() === 'content-type')