Skip to content

Commit 5efb47e

Browse files
authored
fix(storage): percent-encode object key in multipart fallback URL (#4872)
* fix(storage): percent-encode object key in multipart fallback URL buildObjectFallbackUrl built the object URL from a raw key. Keys with spaces or reserved characters (and the pre-existing AWS branch) would produce a structurally invalid location. Encode the key per path segment (preserving '/' separators) across all branches (AWS, custom path-style, virtual-hosted). * refactor(storage): clearer per-segment key encoding in fallback URL * test(storage): cover multipart fallback URL (AWS, R2 virtual-hosted, MinIO path-style, key encoding)
1 parent 80d966d commit 5efb47e

2 files changed

Lines changed: 80 additions & 3 deletions

File tree

apps/sim/lib/uploads/providers/s3/client.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const {
1212
mockPutObjectCommand,
1313
mockGetObjectCommand,
1414
mockDeleteObjectCommand,
15+
mockCompleteMultipartUploadCommand,
1516
mockGetSignedUrl,
1617
mockEnv,
1718
mockS3Config,
@@ -51,6 +52,7 @@ const {
5152
mockPutObjectCommand: vi.fn().mockImplementation(class {}),
5253
mockGetObjectCommand: vi.fn().mockImplementation(class {}),
5354
mockDeleteObjectCommand: vi.fn().mockImplementation(class {}),
55+
mockCompleteMultipartUploadCommand: vi.fn().mockImplementation(class {}),
5456
mockGetSignedUrl: vi.fn(),
5557
mockEnv,
5658
}
@@ -61,6 +63,7 @@ vi.mock('@aws-sdk/client-s3', () => ({
6163
PutObjectCommand: mockPutObjectCommand,
6264
GetObjectCommand: mockGetObjectCommand,
6365
DeleteObjectCommand: mockDeleteObjectCommand,
66+
CompleteMultipartUploadCommand: mockCompleteMultipartUploadCommand,
6467
}))
6568

6669
vi.mock('@aws-sdk/s3-request-presigner', () => ({
@@ -92,6 +95,7 @@ vi.mock('@/lib/uploads/config', () => ({
9295
}))
9396

9497
import {
98+
completeS3MultipartUpload,
9599
deleteFromS3,
96100
downloadFromS3,
97101
getPresignedUrl,
@@ -398,4 +402,70 @@ describe('S3 Client', () => {
398402
})
399403
})
400404
})
405+
406+
describe('completeS3MultipartUpload fallback location', () => {
407+
const parts = [{ ETag: 'etag-1', PartNumber: 1 }]
408+
409+
it('uses the SDK-provided Location when present', async () => {
410+
mockSend.mockResolvedValueOnce({ Location: 'https://provided.example.com/object' })
411+
412+
const result = await completeS3MultipartUpload('kb/uuid-file.txt', 'upload-1', parts)
413+
414+
expect(result.location).toBe('https://provided.example.com/object')
415+
expect(result.key).toBe('kb/uuid-file.txt')
416+
expect(result.path).toBe('/api/files/serve/kb%2Fuuid-file.txt')
417+
})
418+
419+
it('falls back to an AWS virtual-hosted URL when Location is absent', async () => {
420+
mockSend.mockResolvedValueOnce({})
421+
422+
const result = await completeS3MultipartUpload('kb/uuid-file.txt', 'upload-1', parts)
423+
424+
expect(result.location).toBe(
425+
'https://test-kb-bucket.s3.test-region.amazonaws.com/kb/uuid-file.txt'
426+
)
427+
})
428+
429+
it('builds a path-style fallback URL for a custom endpoint with forcePathStyle', async () => {
430+
mockS3Config.endpoint = 'https://minio.example.com'
431+
mockS3Config.forcePathStyle = true
432+
mockSend.mockResolvedValueOnce({})
433+
434+
const result = await completeS3MultipartUpload('kb/uuid-file.txt', 'upload-1', parts)
435+
436+
expect(result.location).toBe('https://minio.example.com/test-kb-bucket/kb/uuid-file.txt')
437+
})
438+
439+
it('builds a virtual-hosted fallback URL for a custom endpoint without forcePathStyle', async () => {
440+
mockS3Config.endpoint = 'https://account.r2.cloudflarestorage.com'
441+
mockS3Config.forcePathStyle = false
442+
mockSend.mockResolvedValueOnce({})
443+
444+
const result = await completeS3MultipartUpload('kb/uuid-file.txt', 'upload-1', parts)
445+
446+
expect(result.location).toBe(
447+
'https://test-kb-bucket.account.r2.cloudflarestorage.com/kb/uuid-file.txt'
448+
)
449+
})
450+
451+
it('strips a trailing slash from the custom endpoint before appending the key', async () => {
452+
mockS3Config.endpoint = 'https://minio.example.com/'
453+
mockS3Config.forcePathStyle = true
454+
mockSend.mockResolvedValueOnce({})
455+
456+
const result = await completeS3MultipartUpload('kb/uuid-file.txt', 'upload-1', parts)
457+
458+
expect(result.location).toBe('https://minio.example.com/test-kb-bucket/kb/uuid-file.txt')
459+
})
460+
461+
it('percent-encodes special characters per path segment, preserving slashes', async () => {
462+
mockSend.mockResolvedValueOnce({})
463+
464+
const result = await completeS3MultipartUpload('kb/uuid-my file.txt', 'upload-1', parts)
465+
466+
expect(result.location).toBe(
467+
'https://test-kb-bucket.s3.test-region.amazonaws.com/kb/uuid-my%20file.txt'
468+
)
469+
})
470+
})
401471
})

apps/sim/lib/uploads/providers/s3/client.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -394,18 +394,25 @@ export async function getS3MultipartPartUrls(
394394
* addressing mode — path-style for MinIO/Ceph (`forcePathStyle`), virtual-hosted
395395
* (bucket as a subdomain) for R2 and friends. Falls back to the AWS
396396
* virtual-hosted host when no custom endpoint is set.
397+
*
398+
* The key is percent-encoded per path segment (preserving `/` separators) so
399+
* keys containing spaces or reserved characters still yield a valid URL.
397400
*/
398401
function buildObjectFallbackUrl(bucket: string, region: string, key: string): string {
402+
const encodedKey = key
403+
.split('/')
404+
.map((segment) => encodeURIComponent(segment))
405+
.join('/')
399406
if (S3_CONFIG.endpoint) {
400407
const base = S3_CONFIG.endpoint.replace(/\/+$/, '')
401408
if (S3_CONFIG.forcePathStyle) {
402-
return `${base}/${bucket}/${key}`
409+
return `${base}/${bucket}/${encodedKey}`
403410
}
404411
const url = new URL(base)
405412
url.hostname = `${bucket}.${url.hostname}`
406-
return `${url.origin}/${key}`
413+
return `${url.origin}/${encodedKey}`
407414
}
408-
return `https://${bucket}.s3.${region}.amazonaws.com/${key}`
415+
return `https://${bucket}.s3.${region}.amazonaws.com/${encodedKey}`
409416
}
410417

411418
/**

0 commit comments

Comments
 (0)