Skip to content

CORS: running global allowCorsRequests alongside wheels.middleware.Cors stacks duplicate headers — duplicate Access-Control-Allow-Origin makes browsers reject the response #3114

Closed

Description

@bpamiri

Summary

The 3.x→4.x upgrade guide describes running the legacy global CORS settings and the new wheels.middleware.Cors simultaneously during migration, claiming non-OPTIONS responses carry the middleware's headers because "middleware cfheader overwrites". Live verification (Lucee 7, demo app) shows the layers are additive, not overwriting — and the overlap case emits a spec-violating response.

Repro

// config/settings.cfm
set(allowCorsRequests=true);
set(accessControlAllowOrigin="https://app.example");
set(middleware=[new wheels.middleware.Cors(allowOrigins="https://app.example")]);
curl -is -H 'Origin: https://app.example' http://localhost:<port>/probe/index | grep -i access-control

Observed

Access-Control-Allow-Headers: Origin, Content-Type, X-Auth-Token, X-Requested-By, X-Requested-With
Access-Control-Allow-Headers: Content-Type,Authorization,X-Requested-With
Access-Control-Allow-Origin: https://app.example
Access-Control-Allow-Origin: https://app.example
Vary: Origin
Vary: Origin
Access-Control-Allow-Methods: GET, POST, PATCH, PUT, DELETE, OPTIONS
Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE,OPTIONS

Per the Fetch spec, multiple Access-Control-Allow-Origin values cause browsers to fail the CORS check — so the exact origins BOTH layers allow (the ones the migrating app cares about most) break in browsers while curl looks fine.

Additional observations from the same session:

  • OPTIONS requests are answered solely by the global path (EventMethods.cfc:274-297 sets headers in onRequestStart and aborts before the middleware pipeline) — that half of the guide is correct.
  • The middleware pipeline does not run on the public root/congrats route (.root(method="get") with no to), so only global headers appear there.

Root cause

$setCORSHeaders (onRequestStart) and Cors.cfc::handle() (cfheader, lines 122-125) both APPEND headers; nothing dedupes or arbitrates when both systems are active.

Suggested fix

Any of:

  1. When application.wheels.middleware contains a Cors instance and allowCorsRequests=true, log a wheels_security warning (or skip the global emission) — the two systems should not both fire.
  2. Have the dispatch-level Cors middleware suppress/replace the global headers for matched requests.
  3. At minimum, fix the upgrade guide (3x-to-4x.mdx CORS section) to say: disable allowCorsRequests as soon as the middleware is configured; do not run both.

Dedupe: distinct from the merged CORS hardening (#2703/#2704/#2707/#2708/#2724/#2725/#2633 — all middleware-internal) and from P1's cors.mdx findings (#3076 is onError masking). No overlap with #3100–#3108. Found during the guide behavioral audit P2 (p2-5-upgrading, claim 3x4x-04).

🤖 Generated with Claude Code

Activity

  1. wheels-bot commented on Jun 12, 2026

    @wheels-bot
    Contributor

    Wheels Bot — Triage

    Classified as bug.

    The report has a clear what-happened (duplicate Access-Control-Allow-Origin + other CORS headers emitted by two systems) vs what-expected (a single, spec-compliant value), a concrete Lucee 7 repro, and observed curl -is output. Per the Fetch spec, multiple Access-Control-Allow-Origin values cause browsers to reject the response — observable wrong behavior, reproducible in isolation. The doc-fix suggestion (option 3) is secondary; the framework-level header stacking is the defect.

    Suspected layer

    middleware — see .ai/wheels/ middleware references and vendor/wheels/middleware/Cors.cfc.

    Fix sketch

    The two CORS emitters are \$setCORSHeaders (global path, onRequestStart in vendor/wheels/events/EventMethods.cfc, fired when allowCorsRequests=true) and Cors.cfc::handle() (vendor/wheels/middleware/Cors.cfc:122-125), both of which append via cfheader with nothing arbitrating when both are active. Likely change touches Cors.cfc and/or the global emitter so they don't double-fire: either (a) the dispatch-level Cors middleware suppresses/replaces the global headers for matched requests, or (b) the global emitter skips emission (with a wheels_security warning) when application.wheels.middleware contains a Cors instance. Both are arbitration-policy choices with trade-offs; the upgrade guide CORS section (3x-to-4x.mdx) should also be corrected to say "disable allowCorsRequests once the middleware is configured."

    Confidence: medium

    The defect is clear, but the fix touches vendor/wheels/middleware/** (auto-downgrade rule), and choosing among the three arbitration approaches is a design trade-off rather than a mechanical one-file change.

    <!-- wheels-bot:triage:3114 -->
    <!-- wheels-bot:triage-class:bug -->
    <!-- wheels-bot:triage-confidence:medium -->

  2. wheels-bot commented on Jun 12, 2026

    @wheels-bot
    Contributor

    Wheels Bot — Fix proposed

    Draft PR: #3128

    Spec: vendor/wheels/tests/specs/middleware/CorsGlobalArbitrationSpec.cfc — failing → passing
    Implementation: vendor/wheels/Global.cfc ($corsMiddlewareActive() + one-time deferral warning), vendor/wheels/events/EventMethods.cfc (global onRequestStart CORS path + OPTIONS abort now defer to a registered wheels.middleware.Cors)

    The fix follows triage option 1/2 combined: when a wheels.middleware.Cors instance is registered, the legacy global path steps aside entirely (no $setCORSHeaders, no global OPTIONS abort), leaving the dispatch-level middleware as the single source of truth — so the duplicate Access-Control-Allow-* headers no longer stack. A one-time wheels.log warning nudges operators to drop the now-redundant allowCorsRequests setting (the doc half of option 3 is left to bot-update-docs.yml).

    Note: the bot's sandbox has no wheels CLI, so tools/test-local.sh could not be run here — verification is deferred to the CI cross-engine matrix on the PR. The new spec uses only cross-engine-safe constructs.

    A human review is required before merge. The Reviewer will weigh in shortly. Doc updates (MDX guides, .ai/wheels/, CLAUDE.md) are handled separately by bot-update-docs.yml.

  3. added a commit that references this issue on Jun 12, 2026
    8c9a232
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions