From 0f432c87abe0fa61fb31160a4287d3d6ecd6dd30 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Fri, 8 May 2026 10:15:02 -0400 Subject: [PATCH] fix(s3): percent-encode object keys in download URLs `getObjectPublicUrl` interpolated S3 keys directly into the URL path. S3 keys may legally contain reserved URI delimiters (`?`, `#`), space, `+`, etc., per AWS object key naming guidelines, and `ListObjectsV2` returns them verbatim. Per RFC 3986, `?` starts a query string and `#` a fragment, so a key like `scan?1.dcm` was sent as `GET /scan?1.dcm` (object `scan` + query `1.dcm` => 404), and `seg#1.dcm` had its fragment stripped before the request even left the browser. Split keys on `/` and `encodeURIComponent` each segment to preserve the hierarchy while encoding everything else. Tests cover `?`, `#`, space, `+`, and a multi-segment key. --- src/io/__tests__/amazonS3.spec.ts | 27 +++++++++++++++++++++++++++ src/io/amazonS3.ts | 7 ++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/io/__tests__/amazonS3.spec.ts b/src/io/__tests__/amazonS3.spec.ts index 55e598c91..2a9702cfa 100644 --- a/src/io/__tests__/amazonS3.spec.ts +++ b/src/io/__tests__/amazonS3.spec.ts @@ -77,6 +77,33 @@ describe('amazonS3', () => { expect(init).toBeUndefined(); }); + it('percent-encodes reserved chars in keys but preserves slashes', async () => { + fetchSpy.mockResolvedValueOnce( + okResponse( + xmlListResponse([ + 'scan?1.dcm', + 'seg#1.dcm', + 'my scan.dcm', + 'a+b.dcm', + 'sub/dir/file.dcm', + ]) + ) + ); + + const urls: string[] = []; + await getObjectsFromS3('s3://bucket/prefix', (_name, url) => + urls.push(url) + ); + + expect(urls).toEqual([ + 'https://bucket.s3.amazonaws.com/scan%3F1.dcm', + 'https://bucket.s3.amazonaws.com/seg%231.dcm', + 'https://bucket.s3.amazonaws.com/my%20scan.dcm', + 'https://bucket.s3.amazonaws.com/a%2Bb.dcm', + 'https://bucket.s3.amazonaws.com/sub/dir/file.dcm', + ]); + }); + it('follows pagination via continuation token', async () => { fetchSpy .mockResolvedValueOnce( diff --git a/src/io/amazonS3.ts b/src/io/amazonS3.ts index ba2d735f8..a78c10774 100644 --- a/src/io/amazonS3.ts +++ b/src/io/amazonS3.ts @@ -10,8 +10,13 @@ export const isAmazonS3Uri = (uri: string) => export type ObjectAvailableCallback = (name: string, url: string) => void; +// Percent-encode each path segment so keys containing reserved URI chars +// (`?`, `#`, space, `+`, …) round-trip correctly. Slashes are preserved. +const encodeS3Key = (key: string) => + key.split('/').map(encodeURIComponent).join('/'); + const getObjectPublicUrl = (bucket: string, key: string) => - `https://${bucket}.s3.amazonaws.com/${key}`; + `https://${bucket}.s3.amazonaws.com/${encodeS3Key(key)}`; const buildListUrl = ( bucket: string,