From 267eb86e79af9d282bfbb642f96c579830ba77d4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 11:13:02 +0000 Subject: [PATCH 1/5] test(controller): prove-red B5 B6 and request-lifecycle SHOULDs Adversarial specs for $callAction ViewNotFound masking, $useLayout later-match wipe, redirect-only action cache, live filterChain("all"), processAction halt return, $findRoute multi-name fail-open, processRequest CSRF ignore, prepend through-order, redirectTo(url=) encoding, and blank layout-function returns. Signed-off-by: Cursor Agent Co-authored-by: Peter Amiri --- .../_assets/controllers/HardenerLifecycle.cfc | 20 ++ .../hardener/ControllerHardenerShouldSpec.cfc | 238 ++++++++++++++++++ .../specs/hardener/DispatchHardenerSpec.cfc | 87 ++++++- 3 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 vendor/wheels/tests/specs/hardener/ControllerHardenerShouldSpec.cfc diff --git a/vendor/wheels/tests/_assets/controllers/HardenerLifecycle.cfc b/vendor/wheels/tests/_assets/controllers/HardenerLifecycle.cfc index b6a10112f..a57501632 100644 --- a/vendor/wheels/tests/_assets/controllers/HardenerLifecycle.cfc +++ b/vendor/wheels/tests/_assets/controllers/HardenerLifecycle.cfc @@ -19,6 +19,26 @@ component extends="Controller" { renderText(request.hardenerCachePayload); } + function cachedRedirect() { + redirectTo(url = "/hardener-redirect-target", delay = true); + } + + function noView() { + // Intentionally empty: $callAction auto-renders and there is no view file. + } + + public any function explodingLayout() { + Throw(type = "Wheels.HardenerLayoutError", message = "layout exploded on purpose"); + } + + public any function blankLayout() { + return ""; + } + + public any function namedLayout() { + return "hardener_named_layout"; + } + private function denyUnlessAllowed() { request.hardenerDenyRan = true; if (!StructKeyExists(request, "hardenerAllow") || !request.hardenerAllow) { diff --git a/vendor/wheels/tests/specs/hardener/ControllerHardenerShouldSpec.cfc b/vendor/wheels/tests/specs/hardener/ControllerHardenerShouldSpec.cfc new file mode 100644 index 000000000..3f4878b5a --- /dev/null +++ b/vendor/wheels/tests/specs/hardener/ControllerHardenerShouldSpec.cfc @@ -0,0 +1,238 @@ +/** + * Hardener BLOCKERs B5–B6 and controller-lifecycle SHOULDs. + * + * Directory-scoped so `wheels test --core --ci --filter=hardener` + * discovers this folder (a single-file directory= scope finds 0 bundles). + */ +component extends="wheels.WheelsTest" { + + function run() { + + g = application.wo + + describe("B5 $callAction does not remap real render errors to ViewNotFound", () => { + + beforeEach(() => { + params = {controller = "hardenerLifecycle", action = "noView"} + _controller = g.controller("hardenerLifecycle", params) + _classData = _controller.$getControllerClassData() + _priorLayouts = Duplicate(_classData.layouts) + }) + + afterEach(() => { + _classData.layouts = _priorLayouts + }) + + it("still maps a genuine missing view to ViewNotFound", () => { + var thrown = {type = ""} + try { + _controller.$callAction(action = "noView") + } catch (any e) { + thrown.type = e.type + } + expect(thrown.type).toBe("Wheels.ViewNotFound") + }) + + it("preserves a layout function error when the action view is missing", () => { + _controller.usesLayout(template = "explodingLayout") + + var thrown = {type = ""} + try { + _controller.$callAction(action = "noView") + } catch (any e) { + thrown.type = e.type + } + expect(thrown.type).toBe("Wheels.HardenerLayoutError") + }) + + }) + + describe("B6 $useLayout later non-match does not wipe a prior match", () => { + + beforeEach(() => { + params = {controller = "hardenerLifecycle", action = "secret"} + _controller = g.controller("hardenerLifecycle", params) + _classData = _controller.$getControllerClassData() + _priorLayouts = Duplicate(_classData.layouts) + }) + + afterEach(() => { + _classData.layouts = _priorLayouts + }) + + it("keeps the first matching usesLayout when a later only= does not apply", () => { + _controller.usesLayout(template = "admin", only = "secret") + _controller.usesLayout(template = "public", only = "index") + + expect(_controller.$useLayout("secret")).toBe("admin") + }) + + it("lets a later matching usesLayout override an earlier match", () => { + _controller.usesLayout(template = "admin") + _controller.usesLayout(template = "special", only = "secret") + + expect(_controller.$useLayout("secret")).toBe("special") + expect(_controller.$useLayout("index")).toBe("admin") + }) + + it("still uses useDefault when no usesLayout matches", () => { + _controller.usesLayout(template = "admin", only = "secret", useDefault = false) + _controller.usesLayout(template = "public", only = "index") + + expect(_controller.$useLayout("list")).toBeTrue() + }) + + }) + + describe("SHOULD $callActionAndAddToCache does not cache a redirect-only response", () => { + + beforeEach(() => { + _hadCacheActions = StructKeyExists(application.wheels, "cacheActions") + if (_hadCacheActions) { + _priorCacheActions = application.wheels.cacheActions + } + application.wheels.cacheActions = true + _originalForm = Duplicate(form) + StructClear(form) + _controller = g.controller("hardenerLifecycle", {controller = "hardenerLifecycle", action = "cachedRedirect"}) + _controller.$clearCachableActions() + g.$clearCache("action") + }) + + afterEach(() => { + _controller.$clearCachableActions() + g.$clearCache("action") + StructClear(form) + StructAppend(form, _originalForm, false) + if (_hadCacheActions) { + application.wheels.cacheActions = _priorCacheActions + } else { + StructDelete(application.wheels, "cacheActions") + } + }) + + it("re-runs the redirect on a second request instead of serving a blank 200", () => { + _controller.caches(action = "cachedRedirect") + + var first = g.controller("hardenerLifecycle", {controller = "hardenerLifecycle", action = "cachedRedirect"}) + first.processAction() + expect(first.$performedRedirect()).toBeTrue() + + var second = g.controller("hardenerLifecycle", {controller = "hardenerLifecycle", action = "cachedRedirect"}) + second.processAction() + expect(second.$performedRedirect()).toBeTrue() + expect(second.getRedirect().url).toInclude("/hardener-redirect-target") + }) + + }) + + describe("SHOULD filterChain(all) returns a copy", () => { + + beforeEach(() => { + params = {controller = "hardenerLifecycle", action = "secret"} + _controller = g.controller("hardenerLifecycle", params) + _classData = _controller.$getControllerClassData() + _priorFilters = Duplicate(_classData.filters) + }) + + afterEach(() => { + _classData.filters = _priorFilters + }) + + it("does not let callers mutate the live $class.filters array", () => { + var chain = _controller.filterChain("all") + var originalLen = ArrayLen(chain) + ArrayAppend(chain, {through = "hardenerMutated"}) + + expect(ArrayLen(_controller.filterChain("all"))).toBe(originalLen) + }) + + }) + + describe("SHOULD processAction returns false when a before filter halts", () => { + + beforeEach(() => { + request.hardenerSecretRan = false + request.hardenerDenyRan = false + request.hardenerAllow = false + params = {controller = "hardenerLifecycle", action = "secret"} + _controller = g.controller("hardenerLifecycle", params) + }) + + it("returns false when a before filter returns false", () => { + expect(_controller.processAction()).toBeFalse() + expect(request.hardenerSecretRan).toBeFalse() + }) + + it("returns true when the action is allowed to run", () => { + request.hardenerAllow = true + expect(_controller.processAction()).toBeTrue() + expect(request.hardenerSecretRan).toBeTrue() + }) + + }) + + describe("SHOULD filters prepend keeps through order", () => { + + beforeEach(() => { + params = {controller = "hardenerLifecycle", action = "secret"} + _controller = g.controller("hardenerLifecycle", params) + _classData = _controller.$getControllerClassData() + _priorFilters = Duplicate(_classData.filters) + _controller.setFilterChain([]) + }) + + afterEach(() => { + _classData.filters = _priorFilters + }) + + it("prepends a multi-through list without reversing it", () => { + _controller.filters(through = "existing") + _controller.filters(through = "alpha,bravo,charlie", placement = "prepend") + var chain = _controller.filterChain("all") + + expect(chain[1].through).toBe("alpha") + expect(chain[2].through).toBe("bravo") + expect(chain[3].through).toBe("charlie") + expect(chain[4].through).toBe("existing") + }) + + }) + + describe("SHOULD redirectTo(url=) encodes params like back=", () => { + + beforeEach(() => { + params = {controller = "hardenerLifecycle", action = "secret"} + _controller = g.controller("hardenerLifecycle", params) + }) + + it("percent-encodes query values appended to url=", () => { + _controller.redirectTo(url = "/hardener-target", params = "q=hello world", delay = true) + expect(_controller.getRedirect().url).toInclude("hello%20world") + }) + + }) + + describe("SHOULD blank layout function return uses the default layout", () => { + + beforeEach(() => { + params = {controller = "hardenerLifecycle", action = "noView"} + _controller = g.controller("hardenerLifecycle", params) + _classData = _controller.$getControllerClassData() + _priorLayouts = Duplicate(_classData.layouts) + }) + + afterEach(() => { + _classData.layouts = _priorLayouts + }) + + it("treats a blank string return as useDefault instead of no layout", () => { + _controller.usesLayout(template = "blankLayout") + expect(_controller.$useLayout("noView")).toBeTrue() + }) + + }) + + } + +} diff --git a/vendor/wheels/tests/specs/hardener/DispatchHardenerSpec.cfc b/vendor/wheels/tests/specs/hardener/DispatchHardenerSpec.cfc index 404b50d8f..bfd133685 100644 --- a/vendor/wheels/tests/specs/hardener/DispatchHardenerSpec.cfc +++ b/vendor/wheels/tests/specs/hardener/DispatchHardenerSpec.cfc @@ -1,5 +1,5 @@ /** - * Hardener BLOCKERs B1–B3 (dispatch / request lifecycle). + * Hardener BLOCKERs B1–B3 plus request-lifecycle SHOULDs ($findRoute, processRequest CSRF). * * Directory-scoped so `wheels test --core --ci --filter=hardener` * discovers this folder (a single-file directory= scope finds 0 bundles). @@ -223,6 +223,91 @@ component extends="wheels.WheelsTest" { }) + describe("SHOULD $findRoute multi-name does not fail open", () => { + + beforeEach(() => { + _originalRoutes = Duplicate(application.wheels.routes) + _originalStaticRoutes = StructKeyExists(application.wheels, "staticRoutes") ? StructCopy(application.wheels.staticRoutes) : {} + _originalNamedRoutePositions = StructKeyExists(application.wheels, "namedRoutePositions") ? StructCopy(application.wheels.namedRoutePositions) : {} + application.wheels.routes = [ + { + name = "hardenerWidget", + methods = "get", + foundvariables = "key", + controller = "dummy", + action = "show", + pattern = "hardener-widgets/[key]" + }, + { + name = "hardenerWidget", + methods = "post", + foundvariables = "", + controller = "dummy", + action = "create", + pattern = "hardener-widgets" + } + ] + application.wheels.namedRoutePositions = {hardenerWidget = "1,2"} + }) + + afterEach(() => { + application.wheels.routes = _originalRoutes + application.wheels.staticRoutes = _originalStaticRoutes + application.wheels.namedRoutePositions = _originalNamedRoutePositions + }) + + it("throws RouteNotFound when no same-named candidate matches the method", () => { + var thrown = {type = ""} + try { + g.$findRoute(route = "hardenerWidget", method = "delete") + } catch (any e) { + thrown.type = e.type + } + expect(thrown.type).toBe("Wheels.RouteNotFound") + }) + + it("selects the candidate whose variables and method match instead of the last name", () => { + var found = g.$findRoute(route = "hardenerWidget", method = "get", key = "1") + expect(found.action).toBe("show") + expect(found.methods).toBe("get") + }) + + it("still resolves a matching method when variables are empty", () => { + var found = g.$findRoute(route = "hardenerWidget", method = "post") + expect(found.action).toBe("create") + }) + + }) + + describe("SHOULD processRequest CSRF ignore is opt-in exception not a silent default flip", () => { + + beforeEach(() => { + _originalCgiMethod = request.cgi.request_method + }) + + afterEach(() => { + request.cgi["request_method"] = _originalCgiMethod + }) + + it("keeps the historic processRequest default of CSRF ignore", () => { + var params = {controller = "csrfProtectedWithException", action = "create"} + var body = g.processRequest(params = params, method = "post") + expect(body).toBe("Create ran.") + }) + + it("enforces CSRF when processRequest is asked for exception mode", () => { + var params = {controller = "csrfProtectedWithException", action = "create"} + var thrown = {type = ""} + try { + g.processRequest(params = params, method = "post", csrf = "exception") + } catch (any e) { + thrown.type = e.type + } + expect(thrown.type).toBe("Wheels.InvalidAuthenticityToken") + }) + + }) + } } From f0cd85b81cc8ed1e686a88ecdfd479884a853f06 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 11:15:07 +0000 Subject: [PATCH 2/5] fix(controller): preserve render errors and usesLayout matches Map ViewNotFound only for genuine missing-view includes, stop later non-matching usesLayout from wiping a prior match, skip caching redirect-only action bodies, copy filterChain(), return processAction halt, fail closed on unmatched same-named $findRoute, keep prepend through-order, encode redirectTo(url=) params, and treat a blank layout-function return as the default layout. processRequest CSRF ignore stays the default; csrf=exception is opt-in. Signed-off-by: Cursor Agent Co-authored-by: Peter Amiri --- .../controller-hardener-b5-b6.fixed.md | 8 ++++ .../controller-hardener-shoulds.changed.md | 2 + vendor/wheels/controller/filters.cfc | 11 +++-- vendor/wheels/controller/layouts.cfc | 16 +++++-- vendor/wheels/controller/processing.cfc | 45 ++++++++++++++----- vendor/wheels/controller/redirection.cfc | 8 ++-- vendor/wheels/events/init/functions.cfm | 2 +- vendor/wheels/global/request.cfm | 9 ++-- vendor/wheels/global/routing.cfm | 17 ++++++- .../hardener/ControllerHardenerShouldSpec.cfc | 12 +++++ 10 files changed, 105 insertions(+), 25 deletions(-) create mode 100644 changelog.d/controller-hardener-b5-b6.fixed.md create mode 100644 changelog.d/controller-hardener-shoulds.changed.md diff --git a/changelog.d/controller-hardener-b5-b6.fixed.md b/changelog.d/controller-hardener-b5-b6.fixed.md new file mode 100644 index 000000000..d6e89bf27 --- /dev/null +++ b/changelog.d/controller-hardener-b5-b6.fixed.md @@ -0,0 +1,8 @@ +- `$callAction()` no longer remaps layout or render exceptions to `Wheels.ViewNotFound` just because `action.cfm` is missing; only genuine missing-view includes become 404 +- `$useLayout()` keeps a chosen `usesLayout` match when a later declaration does not apply, instead of resetting to `useDefault` (layout bypass) +- Action cache no longer stores a redirect-only empty body +- `filterChain()` returns a copy so callers cannot mutate the live filter chain +- `$findRoute()` throws `Wheels.RouteNotFound` when no same-named candidate matches, instead of returning the last declaration +- `filters(placement="prepend")` keeps multi-`through` order (`a,b,c` stays `a,b,c` in front of the existing chain) +- `redirectTo(url=)` encodes `params` the same way `back=true` does +- A blank string returned from a layout function uses the default layout, matching the documented contract diff --git a/changelog.d/controller-hardener-shoulds.changed.md b/changelog.d/controller-hardener-shoulds.changed.md new file mode 100644 index 000000000..1a03490e3 --- /dev/null +++ b/changelog.d/controller-hardener-shoulds.changed.md @@ -0,0 +1,2 @@ +- `processAction()` returns `false` when a verification aborts or a before filter returns `false` (the halt signal was previously always `true`) +- `processRequest()` accepts opt-in `csrf="exception"` / `csrf="abort"`; the historic test-helper default remains `ignore` and production `protectsFromForgery()` is unchanged diff --git a/vendor/wheels/controller/filters.cfc b/vendor/wheels/controller/filters.cfc index fdc320f9a..3f2a31434 100644 --- a/vendor/wheels/controller/filters.cfc +++ b/vendor/wheels/controller/filters.cfc @@ -44,8 +44,12 @@ component { } if (arguments.placement == "append") { ArrayAppend(variables.$class.filters, local.filter); + } else if (!ArrayLen(variables.$class.filters)) { + ArrayAppend(variables.$class.filters, local.filter); } else { - ArrayPrepend(variables.$class.filters, local.filter); + // Prepend the through-list as a block so "a,b,c" stays a,b,c + // in front of the existing chain (not c,b,a). + ArrayInsertAt(variables.$class.filters, local.i, local.filter); } } } @@ -86,14 +90,15 @@ component { } // Set all filters to be returned, or loop over them and set only those that match the supplied type to be returned. + // Always return a copy so callers cannot mutate the live $class.filters chain. if (arguments.type == "all") { - local.rv = variables.$class.filters; + local.rv = Duplicate(variables.$class.filters); } else { local.rv = []; local.iEnd = ArrayLen(variables.$class.filters); for (local.i = 1; local.i <= local.iEnd; local.i++) { if (LCase(variables.$class.filters[local.i].type) == LCase(arguments.type)) { - ArrayAppend(local.rv, variables.$class.filters[local.i]); + ArrayAppend(local.rv, Duplicate(variables.$class.filters[local.i])); } } } diff --git a/vendor/wheels/controller/layouts.cfc b/vendor/wheels/controller/layouts.cfc index baf8904ec..0bf4941bc 100644 --- a/vendor/wheels/controller/layouts.cfc +++ b/vendor/wheels/controller/layouts.cfc @@ -80,11 +80,10 @@ component { */ public any function $useLayout(required string $action) { local.rv = true; - local.layoutType = "template"; + local.matched = false; for (local.layout in variables.$class.layouts) { - local.rv = local.layout.useDefault; - + local.layoutType = "template"; if ( (!StructKeyExists(local.layout, "except") || !ListFindNoCase(local.layout.except, arguments.$action)) && (!StructKeyExists(local.layout, "only") || ListFindNoCase(local.layout.only, arguments.$action)) @@ -101,15 +100,24 @@ component { ) { local.invokeArgs = {}; local.invokeArgs.action = arguments.$action; + StructDelete(local, "result"); local.result = $invoke(method = local.layout[local.layoutType], invokeArgs = local.invokeArgs); // If the developer doesn't return anything from the function or if they return a blank string it should use the default layout still. - if (StructKeyExists(local, "result")) { + if (StructKeyExists(local, "result") && !(IsSimpleValue(local.result) && !Len(ToString(local.result)))) { local.rv = local.result; + } else { + local.rv = local.layout.useDefault; } } else { local.rv = local.layout[local.layoutType]; } + local.matched = true; + } else if (!local.matched) { + // Only apply this declaration's useDefault when no prior + // usesLayout has matched. A later non-match must not wipe a + // chosen layout (that was a silent bypass). + local.rv = local.layout.useDefault; } } return local.rv; diff --git a/vendor/wheels/controller/processing.cfc b/vendor/wheels/controller/processing.cfc index 90d305f7d..cae40799f 100644 --- a/vendor/wheels/controller/processing.cfc +++ b/vendor/wheels/controller/processing.cfc @@ -11,6 +11,10 @@ component { public boolean function processAction(string includeFilters = true) { $runCsrfProtection(action = variables.params.action); + // Completed is the halt signal: false when a verification aborted or a + // before filter returned false. Always-true used to make that signal dead. + local.completed = false; + // Check if action should be cached, and if so, cache statically or set the time to use later when caching just the action. local.cache = 0; if ($get("cacheActions") && $hasCachableActions() && flashIsEmpty() && StructIsEmpty(form)) { @@ -116,9 +120,11 @@ component { if ($get("showDebugInformation")) { $debugPoint("afterFilters"); } + + local.completed = local.runAction; } - return true; + return local.completed; } /** @@ -194,9 +200,9 @@ component { & "/" & LCase(arguments.action) & ".cfm"; - if (FileExists(ExpandPath(local.file))) { - Throw(object = e); - } else { + // Only remap genuine missing-view includes. A missing action.cfm + // used to turn every render/layout exception into ViewNotFound. + if ($isMissingViewException(e) && !FileExists(ExpandPath(local.file))) { // For non-HTML formats, provide a more helpful error message if (local.contentType != "html") { $throwErrorOrShow404Page( @@ -211,6 +217,8 @@ component { extendedInfo = "Create a file named `#LCase(arguments.action)#.cfm` in the `app/views/#LCase(ListChangeDelims(variables.$class.name, '/', '.'))#` directory (create the directory as well if it doesn't already exist)." ); } + } else { + Throw(object = e); } } } @@ -227,15 +235,32 @@ component { required string category ) { $callAction(action = arguments.action); - $addToCache( - key = arguments.key, - value = variables.$instance.response, - time = arguments.time, - category = arguments.category - ); + // A redirect-only action has no body. Caching that empty string turns + // the next hit into a blank 200 with no redirect. + if (!$performedRedirect()) { + $addToCache( + key = arguments.key, + value = variables.$instance.response, + time = arguments.time, + category = arguments.category + ); + } return response(); } + /** + * Internal function. True when an auto-render exception is a missing view + * include rather than a layout/helper/runtime error that happened to fire + * while action.cfm was also absent. + */ + public boolean function $isMissingViewException(required any exception) { + if ($isMissingMappedInclude(arguments.exception)) { + return true; + } + local.type = StructKeyExists(arguments.exception, "type") ? ToString(arguments.exception.type) : ""; + return FindNoCase("MissingInclude", local.type) > 0 || local.type == "template"; + } + /** * Internal function. Appends resolved appendToKey segments onto an action cache key. * Every listed item must resolve; silent omission would share one key across users. diff --git a/vendor/wheels/controller/redirection.cfc b/vendor/wheels/controller/redirection.cfc index 4838eaabc..e90373fbc 100644 --- a/vendor/wheels/controller/redirection.cfc +++ b/vendor/wheels/controller/redirection.cfc @@ -136,11 +136,13 @@ component { } local.url = arguments.url; if (Len(arguments.params)) { + local.params = $constructParams(params = arguments.params, encode = arguments.encode); if (Find("?", arguments.url)) { - local.url = "#local.url#&#arguments.params#"; - } else { - local.url = "#local.url#?#arguments.params#"; + local.params = Replace(local.params, "?", "&"); + } else if (Left(local.params, 1) == "&") { + local.params = Replace(local.params, "&", "?", "one"); } + local.url &= local.params; } } else { local.url = uRLFor(argumentCollection = arguments); diff --git a/vendor/wheels/events/init/functions.cfm b/vendor/wheels/events/init/functions.cfm index aab632fcd..e5f6a1659 100644 --- a/vendor/wheels/events/init/functions.cfm +++ b/vendor/wheels/events/init/functions.cfm @@ -531,7 +531,7 @@ appendToLabel = "", encode = true }; - application.$wheels.functions.processRequest = {method = "get", returnAs = "", rollback = false}; + application.$wheels.functions.processRequest = {method = "get", returnAs = "", rollback = false, csrf = "ignore"}; application.$wheels.functions.protectsFromForgery = {with = "exception", only = "", except = ""}; application.$wheels.functions.radioButton = { label = "useDefaultLabel", diff --git a/vendor/wheels/global/request.cfm b/vendor/wheels/global/request.cfm index 25920c797..b85bf999a 100644 --- a/vendor/wheels/global/request.cfm +++ b/vendor/wheels/global/request.cfm @@ -490,13 +490,15 @@ * @returnAs Pass in `struct` to return all information about the request instead of just the final output (`body`). * @rollback Pass in `true` to roll back all database transactions made during the request. * @includeFilters Set to `before` to only execute "before" filters, `after` to only execute "after" filters or `false` to skip all filters. + * @csrf CSRF handling for this request. Default `ignore` preserves the historic test helper. Pass `exception` or `abort` to enforce; this is opt-in and does not change the production `protectsFromForgery()` default. */ public any function processRequest( required struct params, string method, string returnAs, string rollback, - string includeFilters = true + string includeFilters = true, + string csrf = "ignore" ) { $args(name = "processRequest", args = arguments); @@ -528,8 +530,9 @@ local.controller = controller(name = arguments.params.controller, params = arguments.params); - // Set to ignore CSRF errors during testing. - local.controller.protectsFromForgery(with = "ignore"); + // Historic test helper defaults to ignore. Opt in to exception/abort + // without flipping the production protectsFromForgery() default. + local.controller.protectsFromForgery(with = arguments.csrf); local.controller.processAction(includeFilters = arguments.includeFilters); local.response = local.controller.response(); diff --git a/vendor/wheels/global/routing.cfm b/vendor/wheels/global/routing.cfm index 7930fb3fe..cad72c117 100644 --- a/vendor/wheels/global/routing.cfm +++ b/vendor/wheels/global/routing.cfm @@ -110,10 +110,18 @@ local.routePos = application.wheels.namedRoutePositions[arguments.route]; if (Find(",", local.routePos)) { // there are several routes with this name so we need to figure out which one to use by checking the passed in arguments + local.foundRoute = false; + local.methodSpecified = StructKeyExists(arguments, "method") && Len(arguments.method); local.iEnd = ListLen(local.routePos); for (local.i = 1; local.i <= local.iEnd; local.i++) { local.rv = application.wheels.routes[ListGetAt(local.routePos, local.i)]; - local.foundRoute = StructKeyExists(arguments, "method") && local.rv.methods == arguments.method; + // Method is optional: URLFor / redirectTo do not pass it. When it + // is present it must match; when it is absent, variables decide. + local.foundRoute = !local.methodSpecified + || ( + StructKeyExists(local.rv, "methods") + && ListFindNoCase(local.rv.methods, arguments.method) + ); local.jEnd = ListLen(local.rv.foundvariables); for (local.j = 1; local.j <= local.jEnd; local.j++) { local.variable = ListGetAt(local.rv.foundvariables, local.j); @@ -125,6 +133,13 @@ break; } } + if (!local.foundRoute) { + $throwErrorOrShow404Page( + type = "Wheels.RouteNotFound", + message = "Could not find a `#arguments.route#` route that matched the supplied arguments.", + extendedInfo = "Same-named routes are distinguished by HTTP method and required path variables. Passing a method or variables that match none of the candidates is an error, not a fallback to the last declared route." + ); + } } else { local.rv = application.wheels.routes[local.routePos]; } diff --git a/vendor/wheels/tests/specs/hardener/ControllerHardenerShouldSpec.cfc b/vendor/wheels/tests/specs/hardener/ControllerHardenerShouldSpec.cfc index 3f4878b5a..f67e199b7 100644 --- a/vendor/wheels/tests/specs/hardener/ControllerHardenerShouldSpec.cfc +++ b/vendor/wheels/tests/specs/hardener/ControllerHardenerShouldSpec.cfc @@ -111,6 +111,18 @@ component extends="wheels.WheelsTest" { } }) + it("does not store a cache entry for a redirect-only action", () => { + var probeKey = "hardener-redirect-probe-key" + _controller.$callActionAndAddToCache( + action = "cachedRedirect", + time = 60, + key = probeKey, + category = "action" + ) + expect(_controller.$performedRedirect()).toBeTrue() + expect(StructKeyExists(application.wheels.cache.action, probeKey)).toBeFalse() + }) + it("re-runs the redirect on a second request instead of serving a blank 200", () => { _controller.caches(action = "cachedRedirect") From 8d0b3fb8a7b3460c33105520e2e9b4e89ae012eb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 11:18:42 +0000 Subject: [PATCH 3/5] test(controller): isolate usesLayout examples and accept + encoding Clear dummy class layouts between specified_layouts examples so they do not depend on the $useLayout wipe. Accept EncodeForURL space as either %20 or +. Signed-off-by: Cursor Agent Co-authored-by: Peter Amiri --- vendor/wheels/tests/specs/controller/renderingSpec.cfc | 4 ++++ .../tests/specs/hardener/ControllerHardenerShouldSpec.cfc | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/vendor/wheels/tests/specs/controller/renderingSpec.cfc b/vendor/wheels/tests/specs/controller/renderingSpec.cfc index fede8e9e2..82b81d4e8 100644 --- a/vendor/wheels/tests/specs/controller/renderingSpec.cfc +++ b/vendor/wheels/tests/specs/controller/renderingSpec.cfc @@ -682,6 +682,10 @@ component extends="wheels.WheelsTest" { request.cgi.http_x_requested_with = "" params = {controller = "dummy", action = "index"} _controller = application.wo.controller("dummy", params) + // usesLayout writes class-level state. Clear leftovers so each + // example tests one declaration instead of relying on the old + // $useLayout wipe of a prior match. + ArrayClear(_controller.$getControllerClassData().layouts) }) it("is using method match", () => { diff --git a/vendor/wheels/tests/specs/hardener/ControllerHardenerShouldSpec.cfc b/vendor/wheels/tests/specs/hardener/ControllerHardenerShouldSpec.cfc index f67e199b7..8aef18250 100644 --- a/vendor/wheels/tests/specs/hardener/ControllerHardenerShouldSpec.cfc +++ b/vendor/wheels/tests/specs/hardener/ControllerHardenerShouldSpec.cfc @@ -220,7 +220,9 @@ component extends="wheels.WheelsTest" { it("percent-encodes query values appended to url=", () => { _controller.redirectTo(url = "/hardener-target", params = "q=hello world", delay = true) - expect(_controller.getRedirect().url).toInclude("hello%20world") + var dest = _controller.getRedirect().url + expect(dest).notToInclude("hello world") + expect(ReFindNoCase("hello(%20|[+])world", dest) > 0).toBeTrue() }) }) From 974b3645e986bda21881b4dba0dcea5e53fb1ccd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 11:24:29 +0000 Subject: [PATCH 4/5] test(controller): pin showErrorInformation so B5 throws typed errors $throwErrorOrShow404Page aborts without a typed exception when showErrorInformation is off, so leftover CI suite state left thrown.type empty on ViewNotFound and HardenerLayoutError. Signed-off-by: Cursor Agent Co-authored-by: Peter Amiri --- .../hardener/ControllerHardenerShouldSpec.cfc | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/vendor/wheels/tests/specs/hardener/ControllerHardenerShouldSpec.cfc b/vendor/wheels/tests/specs/hardener/ControllerHardenerShouldSpec.cfc index 8aef18250..1ad544033 100644 --- a/vendor/wheels/tests/specs/hardener/ControllerHardenerShouldSpec.cfc +++ b/vendor/wheels/tests/specs/hardener/ControllerHardenerShouldSpec.cfc @@ -13,6 +13,12 @@ component extends="wheels.WheelsTest" { describe("B5 $callAction does not remap real render errors to ViewNotFound", () => { beforeEach(() => { + // $throwErrorOrShow404Page only Throws when showErrorInformation + // is on; otherwise it renders the 404 page and abort — the catch + // never sees a typed Wheels.ViewNotFound. Pin the throw path so + // CI proves the types without changing production 404 behavior. + _priorShowError = application.wheels.showErrorInformation + application.wheels.showErrorInformation = true params = {controller = "hardenerLifecycle", action = "noView"} _controller = g.controller("hardenerLifecycle", params) _classData = _controller.$getControllerClassData() @@ -21,28 +27,27 @@ component extends="wheels.WheelsTest" { afterEach(() => { _classData.layouts = _priorLayouts + application.wheels.showErrorInformation = _priorShowError }) it("still maps a genuine missing view to ViewNotFound", () => { - var thrown = {type = ""} - try { + expect(() => { _controller.$callAction(action = "noView") - } catch (any e) { - thrown.type = e.type - } - expect(thrown.type).toBe("Wheels.ViewNotFound") + }).toThrow("Wheels.ViewNotFound") }) it("preserves a layout function error when the action view is missing", () => { + // Bind the layout fn on this instance so usesLayout / $useLayout + // resolve it as a function (same pattern as renderingSpec). + var boom = function() { + Throw(type = "Wheels.HardenerLayoutError", message = "layout exploded on purpose"); + }; + _controller.explodingLayout = boom _controller.usesLayout(template = "explodingLayout") - var thrown = {type = ""} - try { + expect(() => { _controller.$callAction(action = "noView") - } catch (any e) { - thrown.type = e.type - } - expect(thrown.type).toBe("Wheels.HardenerLayoutError") + }).toThrow("Wheels.HardenerLayoutError") }) }) From 2f440a27e1f96069c527d67f4637177162d567b6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 11:34:52 +0000 Subject: [PATCH 5/5] fix(controller): throw typed ViewNotFound from $callAction Genuine missing views now Throw Wheels.ViewNotFound instead of $throwErrorOrShow404Page include+abort. processAction still presents the production 404 page so HTTP 404 for apps is unchanged. B5 specs pin params.format=html (leftover Accept skipped auto-render) and set showErrorInformation=false so the typed throw is proven. Signed-off-by: Cursor Agent Co-authored-by: Peter Amiri --- .../controller-hardener-b5-b6.fixed.md | 2 +- vendor/wheels/controller/processing.cfc | 130 +++++++++++------- .../hardener/ControllerHardenerShouldSpec.cfc | 30 ++-- 3 files changed, 101 insertions(+), 61 deletions(-) diff --git a/changelog.d/controller-hardener-b5-b6.fixed.md b/changelog.d/controller-hardener-b5-b6.fixed.md index d6e89bf27..fa0989db3 100644 --- a/changelog.d/controller-hardener-b5-b6.fixed.md +++ b/changelog.d/controller-hardener-b5-b6.fixed.md @@ -1,4 +1,4 @@ -- `$callAction()` no longer remaps layout or render exceptions to `Wheels.ViewNotFound` just because `action.cfm` is missing; only genuine missing-view includes become 404 +- `$callAction()` no longer remaps layout or render exceptions to `Wheels.ViewNotFound` just because `action.cfm` is missing; only genuine missing-view includes become typed `Wheels.ViewNotFound`. `$callAction()` now always Throws that type (it no longer include+aborts via `$throwErrorOrShow404Page`). `processAction()` still presents the production 404 page for ViewNotFound, so HTTP 404 for apps is unchanged. - `$useLayout()` keeps a chosen `usesLayout` match when a later declaration does not apply, instead of resetting to `useDefault` (layout bypass) - Action cache no longer stores a redirect-only empty body - `filterChain()` returns a copy so callers cannot mutate the live filter chain diff --git a/vendor/wheels/controller/processing.cfc b/vendor/wheels/controller/processing.cfc index cae40799f..73e10bc9d 100644 --- a/vendor/wheels/controller/processing.cfc +++ b/vendor/wheels/controller/processing.cfc @@ -58,53 +58,75 @@ component { // Only proceed to call the action if a before filter has not // returned false and has not already rendered content. if (local.runAction && !$performedRenderOrRedirect()) { - // Get content from the cache if it exists there and set it to the request scope. If not, the $callActionAndAddToCache function will run, calling the controller action (which in turn sets the content to the request scope). - if (local.cache) { - local.category = "action"; + // $callAction always Throws typed ViewNotFound for a genuine + // missing view. Catch it here and present the 404 page so HTTP + // dispatch keeps the existing production 404 (include+abort + // when showErrorInformation is off). Direct $callAction + // callers — including the B5 specs — still see the type. + // `var` (not local.) so the catch write survives on BoxLang. + var viewNotFound = {hit = false, message = "", extendedInfo = ""}; + try { + // Get content from the cache if it exists there and set it to the request scope. If not, the $callActionAndAddToCache function will run, calling the controller action (which in turn sets the content to the request scope). + if (local.cache) { + local.category = "action"; + + // Create the key for the cache. + local.key = $hashedKey(variables.$class.name, variables.params); - // Create the key for the cache. - local.key = $hashedKey(variables.$class.name, variables.params); + // Evaluate variables and append to the cache key when specified. + // Missing or unresolvable items throw; they are never omitted, + // because a silent skip collapses distinct keys into one shared key. + if (Len(local.appendToKey)) { + local.scopeMap = { + "request": request, + "arguments": arguments, + "application": application, + "session": session, + "variables": variables + }; + local.key = $appendToCacheKey( + key = local.key, + appendToKey = local.appendToKey, + scopeMap = local.scopeMap + ); + } - // Evaluate variables and append to the cache key when specified. - // Missing or unresolvable items throw; they are never omitted, - // because a silent skip collapses distinct keys into one shared key. - if (Len(local.appendToKey)) { - local.scopeMap = { - "request": request, - "arguments": arguments, - "application": application, - "session": session, - "variables": variables - }; - local.key = $appendToCacheKey( - key = local.key, - appendToKey = local.appendToKey, - scopeMap = local.scopeMap + local.conditionArgs = {}; + local.conditionArgs.key = local.key; + local.conditionArgs.category = local.category; + local.executeArgs = {}; + local.executeArgs.controller = variables.params.controller; + local.executeArgs.action = variables.params.action; + local.executeArgs.key = local.key; + local.executeArgs.time = local.cache; + local.executeArgs.category = local.category; + local.lockName = local.category & local.key & application.applicationName; + variables.$instance.response = $doubleCheckedLock( + name = local.lockName, + condition = "$getFromCache", + execute = "$callActionAndAddToCache", + conditionArgs = local.conditionArgs, + executeArgs = local.executeArgs ); } - local.conditionArgs = {}; - local.conditionArgs.key = local.key; - local.conditionArgs.category = local.category; - local.executeArgs = {}; - local.executeArgs.controller = variables.params.controller; - local.executeArgs.action = variables.params.action; - local.executeArgs.key = local.key; - local.executeArgs.time = local.cache; - local.executeArgs.category = local.category; - local.lockName = local.category & local.key & application.applicationName; - variables.$instance.response = $doubleCheckedLock( - name = local.lockName, - condition = "$getFromCache", - execute = "$callActionAndAddToCache", - conditionArgs = local.conditionArgs, - executeArgs = local.executeArgs - ); + // If we didn't render anything from a cached action, we call the action here. + if (!$performedRender()) { + $callAction(action = variables.params.action); + } + } catch (Wheels.ViewNotFound e) { + viewNotFound.hit = true; + viewNotFound.message = e.message; + if (StructKeyExists(e, "extendedInfo")) { + viewNotFound.extendedInfo = e.extendedInfo; + } } - - // If we didn't render anything from a cached action, we call the action here. - if (!$performedRender()) { - $callAction(action = variables.params.action); + if (viewNotFound.hit) { + $throwErrorOrShow404Page( + type = "Wheels.ViewNotFound", + message = viewNotFound.message, + extendedInfo = viewNotFound.extendedInfo + ); } } @@ -203,20 +225,24 @@ component { // Only remap genuine missing-view includes. A missing action.cfm // used to turn every render/layout exception into ViewNotFound. if ($isMissingViewException(e) && !FileExists(ExpandPath(local.file))) { - // For non-HTML formats, provide a more helpful error message + // Always throw a typed ViewNotFound. $throwErrorOrShow404Page + // include+aborts when showErrorInformation is off, which + // hides the type from callers and from TestBox toThrow. + // processAction catches this and presents the 404 page so + // HTTP 404 for apps is unchanged. if (local.contentType != "html") { - $throwErrorOrShow404Page( - type = "Wheels.ViewNotFound", - message = "No content was rendered for the `#arguments.action#` action in the `#variables.$class.name#` controller.", - extendedInfo = "For content type `#local.contentType#`, either: 1) Call a render function (renderText, renderWith, etc.) in your action, 2) Create a view template named `#LCase(arguments.action)#.#local.contentType#.cfm`, or 3) Use onlyProvides() to restrict acceptable formats." - ); + local.viewNotFoundMessage = "No content was rendered for the `#arguments.action#` action in the `#variables.$class.name#` controller."; + local.viewNotFoundExtended = "For content type `#local.contentType#`, either: 1) Call a render function (renderText, renderWith, etc.) in your action, 2) Create a view template named `#LCase(arguments.action)#.#local.contentType#.cfm`, or 3) Use onlyProvides() to restrict acceptable formats."; } else { - $throwErrorOrShow404Page( - type = "Wheels.ViewNotFound", - message = "Could not find the view page for the `#arguments.action#` action in the `#variables.$class.name#` controller.", - extendedInfo = "Create a file named `#LCase(arguments.action)#.cfm` in the `app/views/#LCase(ListChangeDelims(variables.$class.name, '/', '.'))#` directory (create the directory as well if it doesn't already exist)." - ); + local.viewNotFoundMessage = "Could not find the view page for the `#arguments.action#` action in the `#variables.$class.name#` controller."; + local.viewNotFoundExtended = "Create a file named `#LCase(arguments.action)#.cfm` in the `app/views/#LCase(ListChangeDelims(variables.$class.name, '/', '.'))#` directory (create the directory as well if it doesn't already exist)."; } + $header(statusCode = 404); + Throw( + type = "Wheels.ViewNotFound", + message = local.viewNotFoundMessage, + extendedInfo = local.viewNotFoundExtended + ); } else { Throw(object = e); } diff --git a/vendor/wheels/tests/specs/hardener/ControllerHardenerShouldSpec.cfc b/vendor/wheels/tests/specs/hardener/ControllerHardenerShouldSpec.cfc index 1ad544033..6671264f7 100644 --- a/vendor/wheels/tests/specs/hardener/ControllerHardenerShouldSpec.cfc +++ b/vendor/wheels/tests/specs/hardener/ControllerHardenerShouldSpec.cfc @@ -13,13 +13,24 @@ component extends="wheels.WheelsTest" { describe("B5 $callAction does not remap real render errors to ViewNotFound", () => { beforeEach(() => { - // $throwErrorOrShow404Page only Throws when showErrorInformation - // is on; otherwise it renders the 404 page and abort — the catch - // never sees a typed Wheels.ViewNotFound. Pin the throw path so - // CI proves the types without changing production 404 behavior. - _priorShowError = application.wheels.showErrorInformation - application.wheels.showErrorInformation = true - params = {controller = "hardenerLifecycle", action = "noView"} + // Full-suite leftover request.cgi.http_accept (providesSpec + // writes application/json / pdf onto the shared cgi struct) + // makes $requestContentType() non-html, so $callAction skips + // auto-render and both B5 its finish without throwing. + // Pin HTML on params AND Accept so the missing-view path runs. + _priorAccept = StructKeyExists(request, "cgi") && StructKeyExists(request.cgi, "http_accept") + ? request.cgi.http_accept + : "" + if (!StructKeyExists(request, "cgi")) { + request.cgi = {} + } + request.cgi.http_accept = "text/html" + // Prove the production Throw, not the $throwErrorOrShow404Page + // showErrorInformation=true path. $get reads $appKey() so set() + // writes the key $callAction's siblings would consult. + _priorShowError = g.$get("showErrorInformation") + g.set(showErrorInformation = false) + params = {controller = "hardenerLifecycle", action = "noView", format = "html"} _controller = g.controller("hardenerLifecycle", params) _classData = _controller.$getControllerClassData() _priorLayouts = Duplicate(_classData.layouts) @@ -27,7 +38,10 @@ component extends="wheels.WheelsTest" { afterEach(() => { _classData.layouts = _priorLayouts - application.wheels.showErrorInformation = _priorShowError + g.set(showErrorInformation = _priorShowError) + if (StructKeyExists(request, "cgi")) { + request.cgi.http_accept = _priorAccept + } }) it("still maps a genuine missing view to ViewNotFound", () => {