Skip to content

fix(cors): merge Vary headers when both origin and allow-headers emit vary#1396

Open
francisjohnjohnston-web wants to merge 2 commits into
h3js:mainfrom
francisjohnjohnston-web:fix/cors-vary-header-merge
Open

fix(cors): merge Vary headers when both origin and allow-headers emit vary#1396
francisjohnjohnston-web wants to merge 2 commits into
h3js:mainfrom
francisjohnjohnston-web:fix/cors-vary-header-merge

Conversation

@francisjohnjohnston-web

@francisjohnjohnston-web francisjohnjohnston-web commented May 24, 2026

Copy link
Copy Markdown

What

appendCorsPreflightHeaders now correctly emits Vary: origin, access-control-request-headers when both createOriginHeaders and createAllowHeaderHeaders independently contribute a vary key.

Why

The headers object is built by spreading several creators:

const headers = {
  ...createOriginHeaders(event, options),       // emits vary: "origin" for allowlist origins
  ...createAllowHeaderHeaders(event, options),  // emits vary: "access-control-request-headers"
  ...
};

Plain object spread means the second vary key silently overwrites the first. When a server uses a specific origin allowlist (non-*) and has access-control-request-headers in the preflight request, the response Vary header is access-control-request-headers alone — origin is lost.

Without Vary: origin, a CDN may cache the preflight response for origin A and serve it to origin B. Origin B's browser never sees Access-Control-Allow-Origin: B and blocks the actual request — a silent CORS failure that's hard to diagnose because it only appears with caching middleware in front.

Fix

Extract the two objects that may produce vary, collect their values, and join them before writing:

const originHeaders = createOriginHeaders(event, options);
const allowHeaderHeaders = createAllowHeaderHeaders(event, options);
const headers = { ...originHeaders, ...allowHeaderHeaders, ... };
const varyValues = [originHeaders.vary, allowHeaderHeaders.vary].filter(Boolean);
if (varyValues.length > 0) headers.vary = varyValues.join(", ");

Testing

Added the missing scenario to test/unit/cors.test.ts: specific origin allowlist + wildcard allowHeaders + access-control-request-headers present. Test fails before the fix (vary is only "access-control-request-headers"), passes after.

All 48 test files / 1152 tests remain green; tsc --noEmit clean.

Summary by CodeRabbit

  • Bug Fixes

    • ETag matching now properly handles comma-separated lists per RFC 7232.
    • CORS preflight response headers no longer lose vary header values when multiple sources contribute.
  • Tests

    • Added test coverage for ETag list matching and CORS header merging scenarios.

Review Change Stack

safechem and others added 2 commits May 24, 2026 17:18
RFC 7232 §3.2 defines If-None-Match as a comma-separated list of
entity-tags. The previous strict equality check (`ifNonMatch === opts.etag`)
only matched when the header contained exactly one ETag — any client
sending a list (e.g. `If-None-Match: "v1", "v2"`) would never receive
a 304 Not Modified response even when its cached ETag was present.

Fix: split the header on commas, trim whitespace from each token, and
match if any token equals opts.etag.
…ribute

When a specific origin allowlist is combined with wildcard allowHeaders,
two header-builder functions each emit a `vary` key. Plain object spread
overwrites the first: `createAllowHeaderHeaders` (vary: access-control-request-headers)
silently drops `vary: origin` from `createOriginHeaders`.

Result: a CDN caches a preflight response without `Vary: Origin`, then
serves it to a different origin — correct CORS headers never reach that
origin's browser.

Fix: extract both header objects, collect their vary values, and join them
before writing. All 38 existing CORS tests continue to pass; added the
missing test case (specific origin + wildcard allowHeaders + request headers
present).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 24, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR adds RFC 7232 compliance to conditional HTTP caching and fixes vary header composition in CORS preflight responses. ETag matching now treats the If-None-Match header as a comma-separated list of entity-tags instead of requiring exact equality, and CORS preflight headers explicitly merge vary values from multiple header sources.

Changes

HTTP Cache and CORS Utility Improvements

Layer / File(s) Summary
HTTP Cache ETag list matching
src/utils/cache.ts, test/utils.test.ts
ETag comparison now parses If-None-Match as a comma-separated list and matches when any trimmed token equals the configured etag. New test verifies 304 response when if-none-match: "v1", "v2" contains the configured "v2".
CORS Vary header merging
src/utils/cors.ts, test/unit/cors.test.ts
Preflight header construction now explicitly combines vary values from both origin and allow-header helpers into a single comma-separated header instead of relying on spread overwrites. New test validates vary contains both origin and access-control-request-headers when both origin matching and wildcard allow-headers are used.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly related PRs

  • h3js/h3#1352: Also modifies appendCorsPreflightHeaders in src/utils/cors.ts—related PR changes error response header composition while this PR fixes vary header merging.

Suggested reviewers

  • pi0

Poem

🐰 A rabbit hops through headers bright,
ETags split with comma might,
Vary headers merge with care,
RFC compliance everywhere!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title specifically references the main fix in the CORS module (merging Vary headers) but does not mention the parallel cache fix, which is an equally significant change in the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
test/utils.test.ts (1)

563-579: ⚡ Quick win

Add regression cases for If-None-Match: * and weak ETags

This test is solid for comma-separated values; adding wildcard and weak-validator variants would better lock in RFC behavior and prevent future regressions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/utils.test.ts` around lines 563 - 579, Add two regression test cases
alongside the existing "returns 304 when if-none-match is a comma-separated list
containing the etag" test: one that sends If-None-Match: * and asserts 304 when
handleCacheHeaders is called with a matching etag (use the same t.app.use and
t.fetch pattern), and another that uses a weak ETag (e.g., W/"v2") in either the
response etag or the If-None-Match header to assert the correct RFC-compliant
behavior (weak validators should not match strong ETags; add assertions for both
matching weak-to-weak and non-matching weak-to-strong scenarios). Ensure both
tests invoke handleCacheHeaders and check res.status like the existing test.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/utils/cache.ts`:
- Around line 36-38: The current If-None-Match check only does a raw equality
against opts.etag and thus misses wildcard and weak validators; update the match
logic around ifNonMatch.split(...).some(...) to first treat a token of '*' as an
immediate match, and otherwise normalize both sides by trimming, removing an
optional weak prefix (leading "W/"), and stripping surrounding quotes before
comparing to opts.etag (also normalized similarly) so weak ETags match per RFC
7232 §3.2.

---

Nitpick comments:
In `@test/utils.test.ts`:
- Around line 563-579: Add two regression test cases alongside the existing
"returns 304 when if-none-match is a comma-separated list containing the etag"
test: one that sends If-None-Match: * and asserts 304 when handleCacheHeaders is
called with a matching etag (use the same t.app.use and t.fetch pattern), and
another that uses a weak ETag (e.g., W/"v2") in either the response etag or the
If-None-Match header to assert the correct RFC-compliant behavior (weak
validators should not match strong ETags; add assertions for both matching
weak-to-weak and non-matching weak-to-strong scenarios). Ensure both tests
invoke handleCacheHeaders and check res.status like the existing test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d26e58a9-7f56-4172-8964-1b89bde93cff

📥 Commits

Reviewing files that changed from the base of the PR and between 84244b4 and e0d20c2.

📒 Files selected for processing (4)
  • src/utils/cache.ts
  • src/utils/cors.ts
  • test/unit/cors.test.ts
  • test/utils.test.ts

Comment thread src/utils/cache.ts
Comment on lines +36 to +38
// RFC 7232 §3.2: If-None-Match is a comma-separated list of entity-tags.
// A match occurs when any token in the list equals the ETag.
if (ifNonMatch && ifNonMatch.split(",").some((token) => token.trim() === opts.etag)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle wildcard and weak ETag validators in cache matching

Line 38 only does exact token equality, so valid If-None-Match forms like * and weak validators can miss and incorrectly return 200 instead of 304.

💡 Suggested fix
   if (opts.etag) {
     event.res.headers.set("etag", opts.etag);
     const ifNonMatch = event.req.headers.get("if-none-match");
     // RFC 7232 §3.2: If-None-Match is a comma-separated list of entity-tags.
     // A match occurs when any token in the list equals the ETag.
-    if (ifNonMatch && ifNonMatch.split(",").some((token) => token.trim() === opts.etag)) {
+    if (ifNonMatch) {
+      const normalize = (tag: string) => tag.trim().replace(/^W\//i, "");
+      if (
+        ifNonMatch.split(",").some((token) => {
+          const value = token.trim();
+          return value === "*" || normalize(value) === normalize(opts.etag);
+        })
+      ) {
+        cacheMatched = true;
+      }
+    }
-      cacheMatched = true;
-    }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// RFC 7232 §3.2: If-None-Match is a comma-separated list of entity-tags.
// A match occurs when any token in the list equals the ETag.
if (ifNonMatch && ifNonMatch.split(",").some((token) => token.trim() === opts.etag)) {
if (opts.etag) {
event.res.headers.set("etag", opts.etag);
const ifNonMatch = event.req.headers.get("if-none-match");
// RFC 7232 §3.2: If-None-Match is a comma-separated list of entity-tags.
// A match occurs when any token in the list equals the ETag.
if (ifNonMatch) {
const normalize = (tag: string) => tag.trim().replace(/^W\//i, "");
if (
ifNonMatch.split(",").some((token) => {
const value = token.trim();
return value === "*" || normalize(value) === normalize(opts.etag);
})
) {
cacheMatched = true;
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/cache.ts` around lines 36 - 38, The current If-None-Match check
only does a raw equality against opts.etag and thus misses wildcard and weak
validators; update the match logic around ifNonMatch.split(...).some(...) to
first treat a token of '*' as an immediate match, and otherwise normalize both
sides by trimming, removing an optional weak prefix (leading "W/"), and
stripping surrounding quotes before comparing to opts.etag (also normalized
similarly) so weak ETags match per RFC 7232 §3.2.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant