From e305c45166463bb33dff142b354510f79966f6c4 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Sat, 31 Oct 2026 13:06:46 -0500 Subject: [PATCH 1/2] test(file): reject weak If-Match validators --- src/storage/backend/file.test.ts | 12 +++++++++--- src/storage/renderer/renderer.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/storage/backend/file.test.ts b/src/storage/backend/file.test.ts index 8a776beb0..b6a80f785 100644 --- a/src/storage/backend/file.test.ts +++ b/src/storage/backend/file.test.ts @@ -1535,6 +1535,8 @@ describe('FileBackend conditional reads', () => { it.each([ ['a stale weak tag', (_eTag: string) => 'W/"stale-etag"'], + ['a matching weak tag', (eTag: string) => `W/${eTag}`], + ['a matching weak tag in a list', (eTag: string) => `"stale-etag", W/${eTag}`], ['a quoted wildcard', () => '"stale,*,etag"'], ])('rejects if-match with %s before range and cache checks', async (_name, toHeader) => { const head = await ctx.backend.headObject(bucket, key, version) @@ -1550,8 +1552,6 @@ describe('FileBackend conditional reads', () => { ['a wildcard', () => '*'], ['a match in a list', (eTag: string) => `W/"stale-etag", ${eTag}`], ['an unquoted tag', (eTag: string) => eTag.slice(1, -1)], - ['a weak tag', (eTag: string) => `W/${eTag}`], - ['a weak tag in a list', (eTag: string) => `"stale-etag", W/${eTag}`], ])('allows a range when if-match has %s', async (_name, toHeader) => { const head = await ctx.backend.headObject(bucket, key, version) await expect(statusFor({ ifMatch: toHeader(head.eTag), range: 'bytes=0-1' })).resolves.toBe(206) @@ -1814,13 +1814,19 @@ describe('FileBackend copy source preconditions', () => { it.each([ ['a wildcard', () => '*'], - ['a weak etag', () => `W/${sourceETag}`], ['an etag list', () => `"not-the-source-etag", ${sourceETag}`], ['an unquoted etag', () => sourceETag.replace(/"/g, '')], ])('copies when if-match is %s', async (_, ifMatch) => { await expectCopied({ ifMatch: ifMatch() }) }) + it.each([ + ['a matching weak etag', () => `W/${sourceETag}`], + ['a matching weak etag in a list', () => `"not-the-source-etag", W/${sourceETag}`], + ])('rejects the copy when if-match is %s', async (_, ifMatch) => { + await expectPreconditionFailed({ ifMatch: ifMatch() }) + }) + it.each([ 'W/"not-the-source-etag"', '"not-the-source-etag", "another-etag"', diff --git a/src/storage/renderer/renderer.test.ts b/src/storage/renderer/renderer.test.ts index 389bc1235..977871318 100644 --- a/src/storage/renderer/renderer.test.ts +++ b/src/storage/renderer/renderer.test.ts @@ -196,6 +196,31 @@ describe('AssetRenderer public download preconditions', () => { expect(response.json()).toMatchObject({ code: 'PreconditionFailed' }) }) + it.each([undefined, 'bytes=0-6'])('rejects weak If-Match with range %s', async (range) => { + const original = await app.inject('/object/public/bucket/object.txt') + const response = await app.inject({ + url: '/object/public/bucket/object.txt', + headers: { + 'if-match': `W/${original.headers.etag}`, + ...(range ? { range } : {}), + }, + }) + + expect(response.statusCode).toBe(412) + expect(response.json()).toMatchObject({ code: 'PreconditionFailed' }) + }) + + it('uses weak comparison for If-None-Match', async () => { + const original = await app.inject('/object/public/bucket/object.txt') + const response = await app.inject({ + url: '/object/public/bucket/object.txt', + headers: { 'if-none-match': `W/${original.headers.etag}` }, + }) + + expect(response.statusCode).toBe(304) + expect(response.body).toBe('') + }) + it.each([ [412, 'PreconditionFailed', { 'if-match': '"stale-etag"' }], [416, 'InvalidRange', { range: 'bytes=999-1000' }], From b0748bb372b3d2d2f0e5aaeeeeaa1bb4f20acba9 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Sat, 31 Oct 2026 13:09:51 -0500 Subject: [PATCH 2/2] fix(file): use strong comparison for If-Match --- src/storage/backend/file.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/storage/backend/file.ts b/src/storage/backend/file.ts index c500dd125..2fd5f8fa2 100644 --- a/src/storage/backend/file.ts +++ b/src/storage/backend/file.ts @@ -110,7 +110,7 @@ export class FileBackend implements StorageBackendAdapter { // RFC 9110 13.1.4: If-Unmodified-Since is ignored when If-Match is present. const preconditionFailed = headers?.ifMatch !== undefined - ? !matchesETag(headers.ifMatch, eTag) + ? !matchesETag(headers.ifMatch, eTag, 'strong') : headers?.ifUnmodifiedSince !== undefined && toSeconds(lastModified) > toSeconds(new Date(headers.ifUnmodifiedSince)) @@ -125,7 +125,7 @@ export class FileBackend implements StorageBackendAdapter { // RFC 9110 13.1.3: If-Modified-Since is ignored when If-None-Match is present. const notModified = - (headers?.ifNoneMatch && matchesETag(headers.ifNoneMatch, eTag)) || + (headers?.ifNoneMatch && matchesETag(headers.ifNoneMatch, eTag, 'weak')) || (headers?.ifNoneMatch === undefined && headers?.ifModifiedSince && toSeconds(lastModified) <= toSeconds(new Date(headers.ifModifiedSince))) @@ -890,13 +890,13 @@ function assertCopySourcePreconditions( let failed = false if (conditions.ifMatch !== undefined) { - failed = !matchesETag(conditions.ifMatch, eTag) + failed = !matchesETag(conditions.ifMatch, eTag, 'strong') } else if (conditions.ifUnmodifiedSince) { failed = toSeconds(lastModified) > toSeconds(conditions.ifUnmodifiedSince) } if (!failed && conditions.ifNoneMatch !== undefined) { - failed = matchesETag(conditions.ifNoneMatch, eTag) + failed = matchesETag(conditions.ifNoneMatch, eTag, 'weak') } else if (!failed && conditions.ifModifiedSince) { failed = toSeconds(lastModified) <= toSeconds(conditions.ifModifiedSince) } @@ -923,13 +923,17 @@ function unquoteETag(value: string) { .replace(/^"(.*)"$/, '$1') } -function matchesETag(condition: string, eTag: string) { +function matchesETag(condition: string, eTag: string, comparison: 'strong' | 'weak') { const target = unquoteETag(eTag) // RFC 9110 8.8.3: commas are valid inside a quoted entity-tag // so split on commas outside quotes only. return (condition.match(/(?:"[^"]*"|[^,])+/g) ?? []).some((candidate) => { const value = candidate.trim() - return value === '*' || unquoteETag(value) === target + if (value === '*') return true + if (comparison === 'strong' && (value.startsWith('W/') || eTag.trim().startsWith('W/'))) { + return false + } + return unquoteETag(value) === target }) }