Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8651e07
fix(middleware): short-circuit OPTIONS preflight in dispatch when COR…
github-actions[bot] May 15, 2026
ee2579f
docs(web/guides): note that OPTIONS preflight short-circuit requires …
github-actions[bot] May 15, 2026
4b7036f
fix(middleware): address Reviewer A/B consensus findings (round 1)
github-actions[bot] May 15, 2026
45b3f8c
chore(web): refresh visual baseline(s) (blog)
bpamiri May 15, 2026
901bae8
fix(middleware): address Reviewer A round-2 nits
bpamiri May 15, 2026
a7b3ecb
docs(middleware): clarify preflight-context comment in Dispatch.cfc
bpamiri May 15, 2026
9a6993a
Merge remote-tracking branch 'origin/develop' into fix/bot-2703-wheel…
bpamiri May 15, 2026
0614457
Merge remote-tracking branch 'origin/develop' into fix/bot-2703-wheel…
bpamiri May 16, 2026
68381e3
Merge remote-tracking branch 'origin/develop' into fix/bot-2703-wheel…
bpamiri May 16, 2026
f72d8d9
Merge remote-tracking branch 'origin/develop' into fix/bot-2703-wheel…
bpamiri May 16, 2026
2409683
Merge remote-tracking branch 'origin/develop' into fix/bot-2703-wheel…
bpamiri May 16, 2026
03ce081
chore(web): refresh visual baseline(s) (blog)
bpamiri May 16, 2026
65c46fe
Merge remote-tracking branch 'origin/develop' into fix/bot-2703-wheel…
bpamiri May 16, 2026
ebb6df4
Merge remote-tracking branch 'origin/develop' into fix/bot-2703-wheel…
bpamiri May 16, 2026
279bb93
chore(web): refresh visual baseline(s) (blog)
bpamiri May 16, 2026
1438257
Merge branch 'develop' into fix/bot-2703-wheels-middleware-cors-canno…
bpamiri May 16, 2026
13bae60
chore(web): refresh visual baseline(s) (blog)
bpamiri May 16, 2026
c8e84b7
docs(middleware): address Reviewer A/B consensus findings (round 2)
github-actions[bot] May 16, 2026
c6b6787
Merge remote-tracking branch 'origin/develop' into fix/bot-2703-wheel…
bpamiri May 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ All historical references to "CFWheels" in this changelog have been preserved fo

### Fixed

- `wheels.middleware.Cors` now short-circuits unmatched `OPTIONS` preflight requests at the dispatch layer, preserving the legacy `set(allowCorsRequests=true)` contract under the new middleware pipeline. Previously, `$findMatchingRoute()` ran before middleware, so a preflight against a path that only declared `POST` (or any non-`OPTIONS` verb) 404'd with `Wheels.RouteNotFound` before the CORS middleware's preflight branch could fire — leaving the middleware strictly less capable than the 3.x global setting it was meant to replace and breaking cross-origin `POST`/`PUT`/`PATCH`/`DELETE` from configured browsers. `Dispatch.$request()` now checks for an `OPTIONS` verb plus a `wheels.middleware.Cors` instance in the global pipeline and, if both are present, runs the pipeline against a no-op core handler before route matching. Dispatch behavior for `OPTIONS` without CORS middleware (still 404s) and for non-`OPTIONS` verbs (still routed normally) is unchanged (#2703)
- `wheels` `.deb` / `.rpm` Linux packages now ship the lucli-native `wheels-module` artifact, version + channel stamps, and a wrapper that routes through the bundled module — fixing the three v4.0.0 rpm regressions that broke `wheels start` on Rocky Linux during the titan production cutover. (1) `build-linux-packages.sh` now untars `wheels-module-${WHEELS_VERSION}.tar.gz` into `/opt/wheels/module/` instead of unzipping the CommandBox-shaped `wheels-cli-${WHEELS_VERSION}.zip`. (2) The LuCLI binary is staged as `/opt/wheels/wheels` so `basename(argv[0])` is `wheels` when the wrapper execs it — mirroring the brew formula and making LuCLI's module dispatcher resolve `wheels start` against the bundled module. (3) `nfpm-wheels.yaml` and `nfpm-wheels-be.yaml` now declare `/opt/wheels/.version` and `/opt/wheels/.channel` under `contents:` so `wheels --version` no longer returns `unknown (stable)`. (4) `tar` is declared as an rpm + deb runtime dependency since Rocky Linux 10 minimal cloud images do not ship it and any role that unpacks a tarball payload fails silently without it (#2700)
- `wheels.middleware.RateLimiter` now validates `windowSeconds > 0` and `maxRequests >= 0` at construction. Previously, `windowSeconds = 0` leaked a generic CFML `You cannot divide by zero` exception out of the `fixedWindow` and `tokenBucket` strategies (and let every request through on `slidingWindow`), with no pointer back to the misconfigured `set(middleware = [...])` line. The constructor now throws `Wheels.RateLimiter.InvalidConfiguration` with a message naming the bad parameter — matching the pattern already used for `strategy`, `storage`, and `proxyStrategy`. `maxRequests = 0` remains legal (kill-switch idiom for "block every request") (#2693)
- `wheels deploy --version=v1.2.3` (the form documented in the Kamal migration guide) no longer fails with `Invalid value for option '--version': 'v1.2.3' is not a boolean`. picocli treats `--version` as a `versionHelp = true` root flag and absorbs it during arg parsing before `Module.cfc` ever sees the subcommand, so the literal Kamal form was unreachable. The deploy parser now accepts `--release` as a picocli-safe alias (extracted into `cli/lucli/services/deploy/cli/DeployArgsParser.cfc` for unit-testability), and the brew/scoop wrappers rewrite `--version[=val]` → `--release[=val]` when `deploy` is the first positional — so the documented `--version` form keeps working on a current-channel wrapper, and users on an older wrapper can pass `--release` directly (#2674)
Expand Down
48 changes: 48 additions & 0 deletions vendor/wheels/Dispatch.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,39 @@ component output="false" extends="wheels.Global"{
$debugPoint("setup");
}

// CORS preflight short-circuit: when the global middleware pipeline contains
// a `wheels.middleware.Cors` instance, run OPTIONS through the pipeline
// before route matching so unmatched preflight verbs reach the CORS handler
// instead of 404ing in $findMatchingRoute. The legacy
// `set(allowCorsRequests=true)` path aborted OPTIONS in EventMethods.cfc
// before dispatch; this preserves that contract for middleware users.
// See issue #2703.
local.preflightMethod = "";
try {
local.preflightMethod = $getRequestMethod();
} catch (any e) {
// Swallow intentionally: when request.cgi is not yet populated
// (e.g. test contexts or unusual dispatch paths) we fail closed by
// leaving preflightMethod empty so the short-circuit guard below is
// skipped and normal routing proceeds.
}
if (UCase(local.preflightMethod) == "OPTIONS" && $hasPreflightCapableMiddleware()) {
request.wheels.params = {};
local.preflightContext = {
params = {},
route = {},
pathInfo = arguments.pathInfo,
method = local.preflightMethod
};
local.preflightHandler = function(required struct request) {
return "";
};
return variables.$middlewarePipeline.run(
request = local.preflightContext,
coreHandler = local.preflightHandler
);
}

local.params = $paramParser(argumentCollection = arguments);

// Set params in the request scope as well so we can display it in the debug info outside of the controller context.
Expand Down Expand Up @@ -325,6 +358,21 @@ component output="false" extends="wheels.Global"{
}
}

/**
* Returns true if the global middleware pipeline contains a CORS middleware
* instance capable of handling an OPTIONS preflight short-circuit. Used to
* preserve the legacy `allowCorsRequests=true` short-circuit semantics in
* the new middleware pipeline. See issue #2703.
*/
private boolean function $hasPreflightCapableMiddleware() {
for (local.mw in variables.$middlewarePipeline.getMiddleware()) {
if (IsObject(local.mw) && IsInstanceOf(local.mw, "wheels.middleware.Cors")) {
return true;
}
}
return false;
}

/**
* Resolve route-scoped middleware from the matched route's `middleware` property.
* Returns an array of instantiated middleware components.
Expand Down
17 changes: 14 additions & 3 deletions vendor/wheels/middleware/Cors.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,21 @@ component implements="wheels.middleware.MiddlewareInterface" output="false" {
}

// Handle preflight OPTIONS request — return empty response immediately.
// Prefer the request struct passed to the middleware (the canonical
// per-request context, mirroring how RateLimiter resolves remote_addr
// from arguments.request.cgi) and fall back to the engine CGI scope
// when the request context doesn't carry a method. Inside a function,
// a bare `request` reference resolves to the engine REQUEST scope, not
// the function argument — `arguments.request` is required to address
// the passed struct.
local.requestMethod = "GET";
try {
local.requestMethod = cgi.request_method;
} catch (any e) {
if (StructKeyExists(arguments.request, "cgi") && StructKeyExists(arguments.request.cgi, "request_method")) {
local.requestMethod = arguments.request.cgi.request_method;
} else {
try {
local.requestMethod = cgi.request_method;
} catch (any e) {
}
}

if (local.requestMethod == "OPTIONS") {
Expand Down
116 changes: 116 additions & 0 deletions vendor/wheels/tests/specs/middleware/CorsPreflightDispatchSpec.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* Regression test for #2703: wheels.middleware.Cors cannot short-circuit
* OPTIONS preflight because middleware runs AFTER route dispatch.
*
* The legacy `set(allowCorsRequests=true)` path aborted OPTIONS in
* EventMethods.cfc BEFORE route matching. The new middleware pipeline must
* preserve that behavior for cross-origin POST/PUT/PATCH/DELETE preflight
* to function — browsers block the actual request when preflight 404s.
*/
component extends="wheels.WheelsTest" {

function run() {

describe("CORS preflight short-circuit in dispatch", () => {

beforeEach(() => {
_savedMiddleware = StructKeyExists(application.wheels, "middleware")
? Duplicate(application.wheels.middleware) : [];
_savedRoutes = Duplicate(application.wheels.routes);
_savedStaticRoutes = StructKeyExists(application.wheels, "staticRoutes")
? Duplicate(application.wheels.staticRoutes) : {};
_savedCgiMethod = request.cgi.request_method;
application.wheels.routes = [];
application.wheels.staticRoutes = {};
});

afterEach(() => {
application.wheels.middleware = _savedMiddleware;
application.wheels.routes = _savedRoutes;
application.wheels.staticRoutes = _savedStaticRoutes;
request.cgi["request_method"] = _savedCgiMethod;
});

it("does not 404 on OPTIONS preflight when CORS middleware is registered", () => {
// This test validates the dispatch-layer fix: OPTIONS preflight
// reaches the middleware pipeline instead of 404ing in
// $findMatchingRoute. The `result == ""` assertion is satisfied
// by Dispatch's no-op preflightHandler closure, not by Cors's
// own OPTIONS short-circuit branch — Cors.handle() reads
// `cgi.request_method` from the engine CGI scope (which is the
// GET from the test runner request), so the middleware falls
// through to next() and the no-op handler returns "". The
// Cors middleware's own OPTIONS branch is tested in CorsSpec.cfc.
application.wheels.middleware = [
new wheels.middleware.Cors(allowOrigins = "https://portal.pai.com")
];
request.cgi["request_method"] = "OPTIONS";

var d = application.wo.$createObjectFromRoot(
path = "wheels", fileName = "Dispatch", method = "$init"
);

var threw = false;
var result = "preflight-not-reached";
try {
result = d.$request(
pathInfo = "/api/v1/jvm",
scriptName = "",
formScope = {},
urlScope = {}
);
} catch (any e) {
threw = true;
}

expect(threw).toBeFalse();
expect(result).toBe("");
});

it("still 404s on OPTIONS request when no CORS middleware is registered", () => {
// Preserves existing dispatch behavior — we only short-circuit when
// a CORS instance is actually configured.
application.wheels.middleware = [];
request.cgi["request_method"] = "OPTIONS";

var d = application.wo.$createObjectFromRoot(
path = "wheels", fileName = "Dispatch", method = "$init"
);

expect(function() {
d.$request(
pathInfo = "/api/v1/jvm",
scriptName = "",
formScope = {},
urlScope = {}
);
}).toThrow("Wheels.RouteNotFound");
});

it("still routes non-OPTIONS requests through normal dispatch when CORS is registered", () => {
// Sanity check: GET requests should still go through $paramParser
// → $findMatchingRoute and 404 normally when unmatched.
application.wheels.middleware = [
new wheels.middleware.Cors(allowOrigins = "*")
];
request.cgi["request_method"] = "GET";

var d = application.wo.$createObjectFromRoot(
path = "wheels", fileName = "Dispatch", method = "$init"
);

expect(function() {
d.$request(
pathInfo = "/api/v1/jvm",
scriptName = "",
formScope = {},
urlScope = {}
);
}).toThrow("Wheels.RouteNotFound");
});

});

}

}
17 changes: 17 additions & 0 deletions vendor/wheels/tests/specs/middleware/CorsSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,23 @@ component extends="wheels.WheelsTest" {
expect(local.result).toBe("same-origin");
});

it("short-circuits OPTIONS preflight with empty string instead of calling next", function() {
// Cors.handle() prefers request.cgi.request_method (when present)
// over the engine CGI scope, mirroring its http_origin lookup.
// If the OPTIONS branch fires, the result is "" (empty body).
// If the middleware falls through to next(), the closure below
// would return "should-not-reach" instead.
local.cors = new wheels.middleware.Cors(allowOrigins = "https://example.com");
local.reqCtx = {cgi = {request_method = "OPTIONS", http_origin = "https://example.com"}};
local.result = local.cors.handle(
request = local.reqCtx,
next = function(required struct request) {
return "should-not-reach";
}
);
expect(local.result).toBe("");
});

describe("wildcard + credentials validation", function() {

it("throws when allowOrigins is wildcard and allowCredentials is true", function() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ mapper()

Routes under `/api` get the Cors middleware. `resources("pages")` and everything outside the scope do not. Scope-level middleware composes with global middleware — both run.

<Aside type="caution">
The preflight short-circuit that prevents unmatched `OPTIONS` requests from reaching the route table applies only when `Cors` is registered in the **global** pipeline via `config/settings.cfm`. Route-scoped `Cors` declared inside `.scope()` in `config/routes.cfm` does not benefit: route matching runs before route-scoped middleware executes, so a browser preflight to a path that only declares `POST` will 404 with `Wheels.RouteNotFound` unless `Cors` is also in the global pipeline.
</Aside>

## Debugging CORS failures

When a cross-origin request fails, work through this checklist:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ mapper()

Routes under `/api` get the Cors middleware. `resources("pages")` and everything outside the scope do not. Scope-level middleware composes with global middleware — both run.

<Aside type="caution">
The preflight short-circuit that prevents unmatched `OPTIONS` requests from reaching the route table applies only when `Cors` is registered in the **global** pipeline via `config/settings.cfm`. Route-scoped `Cors` declared inside `.scope()` in `config/routes.cfm` does not benefit: route matching runs before route-scoped middleware executes, so a browser preflight to a path that only declares `POST` will 404 with `Wheels.RouteNotFound` unless `Cors` is also in the global pipeline.
</Aside>

## Debugging CORS failures

When a cross-origin request fails, work through this checklist:
Expand Down
Loading