fix(uploads): set Content-Type once on presigned PUTs; document x-goog-meta-folderid for GCS CORS (#6121)

* fix(uploads): set Content-Type once on presigned PUTs; document x-goog-meta-folderid in the GCS CORS example

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.

* chore(uploads): drop inline comment
This commit is contained in:
Waleed
2026-07-30 22:22:56 -07:00
committed by GitHub
parent 413784eceb
commit 48aeac218c
3 changed files with 48 additions and 1 deletions
@@ -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"
],
@@ -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)
+6 -1
View File
@@ -292,7 +292,12 @@ const uploadViaPresignedPut = (opts: UploadViaPutOptions): Promise<void> => {
})
xhr.open('PUT', presignedUrl)
xhr.setRequestHeader('Content-Type', getFileContentType(file))
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)