fix(middleware): emit Vary: Origin from Cors when reflecting allowed origins#2724
Conversation
There was a problem hiding this comment.
Wheels Bot — Reviewer A
TL;DR: This is a clean, focused security-correctness fix. Cors.cfc was failing to emit Vary: Origin when reflecting a specific allowed origin, which could cause CDN/proxy/browser caches to serve a cached response with the wrong Access-Control-Allow-Origin to a different caller. The refactor to $headersFor() is well-structured, all five edge cases are tested, and the commit is in good shape. Two nits are noted below — neither is a blocker. Overall verdict: comment (no correctness, cross-engine, or security blocking items).
Correctness
The logic in $headersFor() is correct in all paths:
allowOrigins |
Origin header |
reflected |
Vary emitted? |
|---|---|---|---|
"*" |
any / none | false |
No — identical response for every caller |
| specific list | matches | true |
Yes — response varies by origin |
| specific list | doesn't match | false |
No — no CORS headers at all |
"" / not configured |
any / none | false |
No — no CORS headers |
All four cases are verified by the test specs.
One potential follow-up (not a blocker): if another middleware upstream has already emitted a Vary: Accept-Encoding header, adding a second cfheader(name="Vary", value="Origin") call may either append a second Vary line or overwrite the first — behavior is engine-dependent. The correct form is Vary: Accept-Encoding, Origin (comma-joined). The legacy Global.cfc::$setCORSHeaders path at line 3565 has the same limitation, so this is pre-existing and not a regression from this PR. Worth a follow-up issue.
// vendor/wheels/middleware/Cors.cfc:92-94
if (local.reflected) {
local.headers["Vary"] = "Origin";
}A future hardening pass could read any pre-existing Vary header and append rather than replace.
Cross-engine
No issues. Specific checks:
for (local.name in local.headers)(line 104) — struct key iteration order is unordered on all engines, but HTTP headers are order-independent so this is safe.$headersForis declaredpublic(line 57) — correct. The$prefix marks it as framework-internal while keeping it testable;privatewould exclude it from test calls.- No
client-scope usage, noapplication-scope function members, no bracket-notation invocation inside closures, no inline closure as anew Component(...)named argument — tests are clean on all of these.
Tests
Five new WheelsTest BDD specs in vendor/wheels/tests/specs/middleware/CorsSpec.cfc:85-130. All extend wheels.WheelsTest, use describe/it/expect correctly (no legacy RocketUnit). The specs test $headersFor() directly rather than observing raw HTTP output — the right approach for unit-level header logic. The happy path (single allowed origin, multiple allowed origins) and all three no-Vary cases (wildcard, disallowed, no-Origin) are covered.
Commits
Single commit 619d2806. Message:
fix(middleware): emit Vary: Origin from Cors when reflecting allowed origins
Type (fix), scope (middleware), subject format, and ≤ 100 char limit all conform to commitlint.config.js. Body explains the why (cache poisoning / cross-origin response leak) rather than the what. Good.
One observation on the DCO trailer:
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
The _shared-rails.md specifies the trailer should use wheels-bot[bot] <wheels-bot[bot]@users.noreply.github.com>. The DCO GitHub App will accept the commit only if the sign-off email matches the commit author's configured email. If the commit author identity is claude[bot] (email 41898282+claude[bot]@users.noreply.github.com), the DCO app will pass; if the author identity was configured as wheels-bot[bot], there will be a DCO mismatch. Worth checking the CI status badge before merge.
Docs
CHANGELOG [Unreleased] → Fixed entry added. AI reference docs and CLAUDE.md update deferred to bot-update-docs.yml follow-up — that is the documented pattern for bot-authored fixes.
Wheels Bot — Docs updatedAdded a doc commit to this PR:
|
There was a problem hiding this comment.
Wheels Bot — Reviewer A
TL;DR: The implementation commit (619d28063) was reviewed in the prior Reviewer A pass and remains clean. The new follow-up commit (0cf399df4) adds the ## CDN and proxy caching section to the v4-0-1-snapshot CORS guide. The prose is accurate on every point that affects user behaviour, but line 78 contains a wording inaccuracy: it says Vary: Origin is emitted "when allowOrigins is a comma-delimited list" when the actual condition is "when allowOrigins is not the wildcard *". A single non-wildcard origin (e.g. allowOrigins="https://myapp.com") also triggers reflection and emits Vary: Origin, which the first spec in CorsSpec.cfc confirms. No correctness, cross-engine, or security blockers. Verdict: comment.
Docs
Wording inaccuracy in the new section — line 78 of v4-0-1-snapshot/digging-deeper/cors.mdx
When the middleware reflects a specific allowed origin — that is, when `allowOrigins` is
a comma-delimited list and the incoming `Origin` matches one of the entries — ...
"Comma-delimited list" implies that multiple entries are required. In reality, the condition is simply "when allowOrigins is not "*" and the incoming Origin matches an entry" — which is true for a single-element allowlist such as allowOrigins="https://myapp.com". The first spec (CorsSpec.cfc line 95) instantiates Cors with a single origin and asserts Vary: Origin is present, confirming the behaviour:
local.cors = new wheels.middleware.Cors(allowOrigins = "https://myapp.com");
// single entry, no comma — still emits Vary: OriginSuggested replacement:
When the middleware reflects a specific allowed origin — that is, when
allowOriginsis not the wildcard"*"and the incomingOriginheader matches one of the entries — it also emitsVary: Originon the response.
The rest of the section (wildcard carve-out, disallowed-origin carve-out, CDN advisory) is accurate.
Commits
Two commits on the branch:
| SHA | Message | Lint |
|---|---|---|
619d28063 |
fix(middleware): emit Vary: Origin from Cors when reflecting allowed origins |
Pass |
0cf399df4 |
docs(web/guides): document Vary: Origin caching behavior in CORS guide |
Pass |
Both conform to commitlint.config.js: valid types (fix, docs), valid scopes (middleware, web/guides), subjects <= 100 chars, not ALL-CAPS. DCO trailers present on both commits using the claude[bot] author identity — consistent with the committer email, so the DCO app should pass.
Implementation (carry-forward from prior review — no change)
No regressions. The $headersFor() refactor is correct across all four origin x allowlist combinations. Cross-engine checks (struct iteration, public + $ prefix, no client-scope usage, no bracket-notation closure calls) remain clean. Five BDD specs cover the happy path and all three no-Vary cases.
Wheels Bot — Reviewer B (round 1)A's review is solid: accurate correctness analysis, no cross-engine issues, well-reasoned nits. One minor imprecision and a coverage gap to note, but neither affects the verdict. SycophancyNone detected. A chose "comment" over "approve" and backed every statement with code quotes and line numbers. False positives
Missed issues
Verdict alignmentA's "comment" verdict is consistent with the findings: two advisory nits (Vary-header append pre-existing limitation and DCO trailer authored by ConvergenceAligned. The core code change ( |
b34a3ab to
f056b2c
Compare
There was a problem hiding this comment.
Wheels Bot — Reviewer A
TL;DR: The new HEAD commit (f056b2c42) is a targeted, accurate one-line docs fix by human contributor Peter Amiri that resolves the only open finding from the prior Reviewer A pass: the "comma-delimited list" wording in the CORS guide was replaced with the precise trigger condition "when allowOrigins is not the wildcard \"*\"". Implementation, tests, and CHANGELOG from prior commits are unchanged and were already found clean. Verdict: approve (submitted as comment — bot cannot approve its own PR; a human reviewer should approve).
Docs
Finding resolved. The previous Reviewer A pass flagged this sentence in web/sites/guides/src/content/docs/v4-0-1-snapshot/digging-deeper/cors.mdx:
When the middleware reflects a specific allowed origin — that is, when
allowOriginsis a comma-delimited list and the incomingOriginmatches one of the entries
"Comma-delimited list" incorrectly implied multiple entries are required. The commit replaces it with:
When the middleware reflects a specific allowed origin — that is, when
allowOriginsis not the wildcard"*"and the incomingOriginheader matches one of the entries
This is accurate. The guard at Cors.cfc:74 is if (variables.allowOrigins != "*" && Len(local.origin)) — the wildcard check is the only structural condition; a single non-wildcard entry like allowOrigins="https://myapp.com" triggers reflection and emits Vary: Origin, exactly as the first spec in CorsSpec.cfc confirms.
No v4-0-0-snapshot CORS guide exists, so no parallel update is needed.
Commits
Four commits on the branch:
| SHA | Author | Message | Lint | DCO |
|---|---|---|---|---|
619d28063 |
claude[bot] | fix(middleware): emit Vary: Origin from Cors when reflecting allowed origins |
Pass | Signed-off-by: claude[bot] ✓ |
0cf399df4 |
claude[bot] | docs(web/guides): document Vary: Origin caching behavior in CORS guide |
Pass | Signed-off-by: claude[bot] ✓ |
6c8715d0e |
github-actions[bot] | chore(web): refresh visual baseline(s) (blog) |
Pass | No trailer — bot author, DCO app typically exempts |
f056b2c42 |
Peter Amiri | docs(web/guides): clarify Vary: Origin trigger condition in CORS guide |
Pass | Signed-off-by: Peter Amiri <petera@pai.com> matches author ✓ |
The 6c8715d0e baseline-refresh commit carries no Signed-off-by trailer. GitHub Actions bot commits are routinely exempt from the DCO app's check (the app whitelists [bot] authors), so this should not block the status check — verify the DCO badge is green before merge.
The blog.png baseline shift (116 714 → 121 482 bytes) is unrelated to the CORS fix and was generated by .github/workflows/refresh-visual-baselines.yml running on this branch, which is standard practice when an intentional content change causes a visual-regression failure. Minor PR hygiene note; not a blocker.
Implementation (carry-forward — no changes since last review)
$headersFor() logic and the five BDD specs are unchanged and were found correct in both prior review passes. Cross-engine checks (struct key iteration, public + $ prefix, no client-scope usage, no bracket-notation closure calls) remain clean.
Wheels Bot — Reviewer B (round 1)A's review of the single human-authored docs commit ( SycophancyNone detected. A chose the "comment" review state (not "approve") and backed every claim with code references. False positives
Missed issuesNone detected. The only new content on this SHA is 8 lines of MDX prose in Verdict alignmentA's "approve" verdict (submitted as "comment" since the bot cannot formally approve its own PR) is consistent with the findings. The new commit is a correct, narrowly-scoped doc clarification. ConvergenceAligned. The core change is clean, the docs section is accurate, and the single new human commit resolves the only open finding from the prior review cycle. Joint recommendation: this PR is review-clean on the current SHA. A human reviewer should formally approve and merge to |
…origins When wheels.middleware.Cors reflects an allowed origin via Access-Control-Allow-Origin: <origin>, the response varies by the request Origin header. Without Vary: Origin, intermediary caches (CDN, reverse proxy, browser disk cache) can serve a cached response with the wrong ACAO to a subsequent request from a different origin — breaking CORS for the second caller or leaking a cached response cross-origin. Refactor handle() to compute headers via a new $headersFor(request) helper (matching the SecurityHeaders pattern), then iterate and emit via cfheader. Vary: Origin is added only when an origin is actually being reflected — wildcard responses and disallowed-origin responses are unchanged. Mirrors the legacy 3.x Global.cfc::$setCORSHeaders behavior at line 3565. Fixes #2707 Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Reviewer A flagged that "when allowOrigins is a comma-delimited list" implies multiple entries are required. The real condition is "when allowOrigins is not the wildcard and the incoming Origin matches one of the entries" — a single-origin allowlist (e.g. allowOrigins="https://myapp.com") also triggers reflection and emits Vary: Origin, as the first spec in CorsSpec.cfc confirms. Signed-off-by: Peter Amiri <petera@pai.com>
f056b2c to
c0b3eab
Compare
There was a problem hiding this comment.
Wheels Bot — Reviewer A
TL;DR: This is a rebased version of the branch reviewed at f056b2c42. The three content commits are unchanged; the only structural difference is that the unrelated chore(web): refresh visual baseline(s) (blog) commit has been dropped, producing a cleaner 3-commit history. All findings from the prior Reviewer A passes remain valid and resolved. No new correctness, cross-engine, or security issues. Verdict: approve (submitted as --comment — bot cannot approve its own PR; a human reviewer should approve).
Correctness
$headersFor() logic is correct across all origin x allowlist combinations, unchanged from prior reviews:
allowOrigins |
Origin header |
reflected |
Vary emitted? |
|---|---|---|---|
"*" |
any / none | false |
No — identical response for every caller |
| specific list | matches | true |
Yes — response varies by origin |
| specific list | doesn't match | false |
No — no CORS headers at all |
"" / not configured |
any / none | false |
No — no CORS headers |
Pre-existing follow-up (not a regression): when allowOrigins is a specific list and no Origin header is present, the middleware currently still emits Access-Control-Allow-Origin (with the configured origin as the literal value) rather than suppressing all CORS headers. Vary is correctly absent in that case. Tracked in prior review; not introduced by this PR.
Cross-engine
No issues. Specific checks (unchanged from prior passes):
for (local.name in local.headers)(line 104) — struct key iteration order is unordered on all engines; HTTP headers are order-independent, so this is safe.$headersFordeclaredpublic(line 57) — correct.privatewould exclude it from test calls and from mixin integration;$prefix marks it as framework-internal without restricting access.- No
client-scope usage, noapplication-scope function members, no bracket-notation call inside a closure, no inline closure as a constructor named argument.
Tests
Five BDD specs under describe("Vary: Origin header") in vendor/wheels/tests/specs/middleware/CorsSpec.cfc:85-130. All extend wheels.WheelsTest, use describe/it/expect correctly. The specs call $headersFor() directly, making them fast and engine-agnostic. Happy path (single origin, multi-origin) and all three no-Vary cases (wildcard, disallowed, no-Origin) are covered.
Minor nit on the "no Origin header" spec: the test description implies no CORS headers at all are emitted, but the current pre-existing behavior is that Access-Control-Allow-Origin is still set (the test only asserts absence of Vary). Low-stakes naming issue; the assertion itself is correct.
Commits
Three commits on the branch:
| SHA | Author | Message | Lint | DCO |
|---|---|---|---|---|
d418bdda8 |
claude[bot] | fix(middleware): emit Vary: Origin from Cors when reflecting allowed origins |
Pass | Signed-off-by: claude[bot] matches author |
46203afa1 |
claude[bot] | docs(web/guides): document Vary: Origin caching behavior in CORS guide |
Pass | Signed-off-by: claude[bot] matches author |
c0b3eabb7 |
Peter Amiri | docs(web/guides): clarify Vary: Origin trigger condition in CORS guide |
Pass | Signed-off-by: Peter Amiri <petera@pai.com> matches author |
All three conform to commitlint.config.js: valid types (fix, docs), valid scopes (middleware, web/guides), subjects <= 100 chars, not ALL-CAPS. DCO trailers present and consistent with author emails.
The chore(web): refresh visual baseline(s) (blog) commit from the prior branch is absent after the rebase, which cleans up the history appropriately.
Docs
The ## CDN and proxy caching section in web/sites/guides/src/content/docs/v4-0-1-snapshot/digging-deeper/cors.mdx is accurate. The wording "when allowOrigins is not the wildcard "*"" correctly describes the guard at Cors.cfc:74:
if (variables.allowOrigins != "*" && Len(local.origin)) {The prior Reviewer A finding ("comma-delimited list" incorrectly implying multiple entries are required) was resolved by Peter Amiri's commit. CHANGELOG [Unreleased] -> Fixed entry is present.
Wheels Bot - Reviewer B (round 1)A's review of the rebased branch is accurate, well-evidenced, and reaches the right verdict. No sycophancy, no false positives, no missed issues. SycophancyNone detected. A recommended approve but correctly submitted as comment-state (bot cannot formally approve its own PR), and backed every claim with code citations or line references. False positivesNone detected. I verified A's claims against the diff:
Missed issuesNone detected. The Vary-header append/overwrite concern (pre-existing: a second cfheader for Vary would clobber an existing Vary: Accept-Encoding) was surfaced in prior review rounds and accepted as non-blocking. A references it in the TL;DR. The DCO claude[bot] vs wheels-bot[bot] author-identity imprecision was also accepted as non-blocking in prior rounds. Nothing new in this diff that A skipped. Verdict alignmentA's approve verdict (submitted as comment) is consistent with the findings: two advisory carry-forward nits, neither blocking, no correctness or cross-engine regressions. ConvergenceAligned. The core change (Cors.cfc, CorsSpec.cfc) is correct and well-tested. The docs are accurate. The two pre-existing nits are unchanged and accepted as non-blocking. Joint recommendation: this PR is review-clean on the current SHA. A human reviewer should formally approve and merge to develop. |
Summary
wheels.middleware.Corsdid not emitVary: Originwhen reflecting an allowedorigin back via
Access-Control-Allow-Origin: <origin>. CDN, reverse-proxy,and browser disk caches could therefore serve a cached response with the wrong
ACAO to a subsequent request from a different origin — breaking CORS for the
second caller or leaking a cached response cross-origin. The legacy 3.x
Global.cfc::$setCORSHeaderspath at line 3565 already emitsVary: Origin;the new middleware now matches.
handle()is refactored to compute the header set via a new$headersFor(request)helper (mirroring theSecurityHeaders.$headers()pattern), then iterate and emit via
cfheader.Vary: Originis added onlywhen an origin is actually being reflected — wildcard (
allowOrigins="*") anddisallowed-origin responses are unchanged.
Related Issue
Fixes #2707
Type of Change
Feature Completeness Checklist
Signed-off-by:trailer on the single commitvendor/wheels/tests/specs/middleware/CorsSpec.cfccover the reflected, multi-origin, wildcard, disallowed, and no-Origin paths. Failing → passing transition was captured locally.bot-update-docs.ymlfollow-upbot-update-docs.ymlfollow-upbot-update-docs.ymlfollow-up[Unreleased]→Fixedwheels.tests.specs.middlewarereports149 pass, 0 fail, 0 erroragainst Lucee 7 + SQLite (was144 pass / 5 errorbefore implementation, demonstrating TDD)Test Plan
describe("Vary: Origin header")invendor/wheels/tests/specs/middleware/CorsSpec.cfc:Vary: Originwhen a single allowed origin is reflectedVary: Originwhen one of multiple allowed origins is reflectedVarywhenallowOrigins="*"(wildcard responses are identical for every caller, so no cache-keying needed)Varywhen the request Origin is not in the allowlist (no CORS headers at all)Varywhen there is noOriginheader on the requestcurl -H "Origin: https://myapp.com" ...against a Wheels app withallowOrigins="https://myapp.com"returns bothAccess-Control-Allow-Origin: https://myapp.comandVary: Origin. WithallowOrigins="*"onlyAccess-Control-Allow-Origin: *is returned.A human review is required before merge. Reviewer A and Reviewer B will weigh in shortly.