Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions changelog.d/controller-hardener-b5-b6.fixed.md
Original file line number Diff line number Diff line change
@@ -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 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
- `$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
2 changes: 2 additions & 0 deletions changelog.d/controller-hardener-shoulds.changed.md
Original file line number Diff line number Diff line change
@@ -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
11 changes: 8 additions & 3 deletions vendor/wheels/controller/filters.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Expand Down Expand Up @@ -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]));
}
}
}
Expand Down
16 changes: 12 additions & 4 deletions vendor/wheels/controller/layouts.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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;
Expand Down
175 changes: 113 additions & 62 deletions vendor/wheels/controller/processing.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -54,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
);
}
}

Expand All @@ -116,9 +142,11 @@ component {
if ($get("showDebugInformation")) {
$debugPoint("afterFilters");
}

local.completed = local.runAction;
}

return true;
return local.completed;
}

/**
Expand Down Expand Up @@ -194,23 +222,29 @@ component {
& "/"
& LCase(arguments.action)
& ".cfm";
if (FileExists(ExpandPath(local.file))) {
Throw(object = e);
} else {
// For non-HTML formats, provide a more helpful error message
// 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))) {
// 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);
}
}
}
Expand All @@ -227,15 +261,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.
Expand Down
8 changes: 5 additions & 3 deletions vendor/wheels/controller/redirection.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion vendor/wheels/events/init/functions.cfm
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 6 additions & 3 deletions vendor/wheels/global/request.cfm
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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();
Expand Down
17 changes: 16 additions & 1 deletion vendor/wheels/global/routing.cfm
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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];
}
Expand Down
Loading