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
1 change: 1 addition & 0 deletions .ai/wheels/security/https-detection.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ component extends="Controller" {
- Returns `true` for HTTPS connections (port 443)
- Works behind load balancers and reverse proxies when `set(trustProxyHeaders=true)` is configured
- `X-Forwarded-Proto` is **not** honored by default; enable with `set(trustProxyHeaders=true)` behind a trusted proxy that overwrites forwarded headers
- `X-Rewrite-URL` / `X-Original-URL` used to recover a blank `path_info` follow the same `trustProxyHeaders` gate
- Test both HTTP and HTTPS scenarios during development

## Common Patterns
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,8 @@ mapper()

Helpers: `linkTo(route="user", key=user.id)`, `urlFor(route="users")`, `redirectTo(route="user", key=user.id)`, `startFormTag(route="user", method="put", key=user.id)`.

`params.controller` / `params.action` come from the matched route. Query string, form, and JSON body cannot retarget them. Wildcard `[controller]` / `[action]` still take those names from the path. `form._method` is honored only on POST and only for `PUT` / `PATCH` / `DELETE`. A before filter that returns `false` skips the action (same as `redirectTo()` / `renderText()`). Filter `type` is case-insensitive. `caches(appendToKey=)` throws `Wheels.KeyNotFound` if a listed path is missing. `X-Rewrite-URL` / `X-Original-URL` follow `set(trustProxyHeaders=true)` like `X-Forwarded-*`.

### Route Model Binding

Resolves `params.key` into a model instance before the action runs. Lands in `params.<singularModelName>`. Throws `Wheels.RecordNotFound` (404) if missing; silently skips if the model class doesn't exist.
Expand Down
6 changes: 6 additions & 0 deletions changelog.d/controller-hardener-b1-b8.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
- Routed `controller` / `action` can no longer be retargeted by query string, form fields, or JSON body (`$ensureControllerAndAction`). Wildcard `[controller]` / `[action]` path names are unchanged
- `$cgiScope()` no longer trusts client-supplied `X-Rewrite-URL` / `X-Original-URL` unless `set(trustProxyHeaders=true)` (same opt-in as `X-Forwarded-*`)
- `form._method` is honored only on POST and only for `PUT` / `PATCH` / `DELETE`, so GET/HEAD cannot become a state-changing verb and POST cannot become a CSRF-safe verb
- A before filter that returns `false` now skips the action and remaining filters (authz fail-closed). `redirectTo()` / `renderText()` still halt as before
- `caches(appendToKey=)` walks the full dotted path and throws `Wheels.KeyNotFound` when a segment is missing, instead of silently omitting it and sharing one cache key
- Filter `type` is case-insensitive (`Before` / `before`, `After` / `after`)
31 changes: 27 additions & 4 deletions vendor/wheels/Dispatch.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -972,14 +972,32 @@ component output="false" extends="wheels.Global"{

/**
* Ensure that the controller and action params exist and are camelized.
* A matched route that names a controller/action always wins over query,
* form, or JSON body values. Mapper.$addRoute already deletes those keys
* when the pattern contains [controller] / [action], so wildcard
* path-derived names still come from $mergeRoutePattern. The pattern
* check is a second gate for route structs built outside $addRoute.
*/
public struct function $ensureControllerAndAction(required struct params, required struct route) {
local.rv = arguments.params;
if (!StructKeyExists(local.rv, "controller")) {
local.pattern = StructKeyExists(arguments.route, "pattern") ? arguments.route.pattern : "";
if (
StructKeyExists(arguments.route, "controller")
&& Len(arguments.route.controller)
&& !FindNoCase("[controller]", local.pattern)
) {
local.rv.controller = arguments.route.controller;
} else if (!StructKeyExists(local.rv, "controller")) {
local.rv.controller = StructKeyExists(arguments.route, "controller") ? arguments.route.controller : "";
}
if (!StructKeyExists(local.rv, "action")) {
if (
StructKeyExists(arguments.route, "action")
&& Len(arguments.route.action)
&& !FindNoCase("[action]", local.pattern)
) {
local.rv.action = arguments.route.action;
} else if (!StructKeyExists(local.rv, "action")) {
local.rv.action = StructKeyExists(arguments.route, "action") ? arguments.route.action : "";
}

// We now need to have dot notation allowed in the controller hence the \.
Expand Down Expand Up @@ -1021,11 +1039,16 @@ component output="false" extends="wheels.Global"{

/**
* Determine HTTP verb used in request.
* `_method` is honored only on POST and only for PUT / PATCH / DELETE —
* the verbs `startFormTag()` emits. GET/HEAD cannot become a
* state-changing verb, and POST cannot become a CSRF-safe verb.
*/
public string function $getRequestMethod() {
// If request is a post, check for alternate verb.
if (request.cgi.request_method == "post" && StructKeyExists(form, "_method")) {
return form["_method"];
local.override = form["_method"];
if (ListFindNoCase("put,patch,delete", local.override)) {
return local.override;
}
}

return request.cgi.request_method;
Expand Down
18 changes: 12 additions & 6 deletions vendor/wheels/controller/filters.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ component {
for (local.i = 1; local.i <= local.iEnd; local.i++) {
local.filter = {};
local.filter.through = local.throughKeysArray[local.i];
local.filter.type = arguments.type;
local.filter.type = LCase(arguments.type);
local.filter.only = arguments.only;
local.filter.except = arguments.except;
local.filter.arguments = {};
Expand Down Expand Up @@ -92,7 +92,7 @@ component {
local.rv = [];
local.iEnd = ArrayLen(variables.$class.filters);
for (local.i = 1; local.i <= local.iEnd; local.i++) {
if (variables.$class.filters[local.i].type == arguments.type) {
if (LCase(variables.$class.filters[local.i].type) == LCase(arguments.type)) {
ArrayAppend(local.rv, variables.$class.filters[local.i]);
}
}
Expand All @@ -103,8 +103,9 @@ component {

/**
* Called twice when processing a request, first for "before" filters and then for "after" filters.
* Returns false when a before filter returns false so processAction can skip the action.
*/
public void function $runFilters(required string type, required string action) {
public boolean function $runFilters(required string type, required string action) {
local.filters = filterChain(arguments.type);
local.iEnd = ArrayLen(local.filters);
for (local.i = 1; local.i <= local.iEnd; local.i++) {
Expand All @@ -118,15 +119,20 @@ component {
);
}
local.result = $invoke(method = local.filter.through, invokeArgs = local.filter.arguments);
// If the filter returned false, we skip the remaining filters.
// If the filter returned false, skip remaining filters. A before
// filter also halts processAction (authz fail-closed).
if ((StructKeyExists(local, "result") && !IsNull(local.result) && !local.result)) {
if (LCase(arguments.type) == "before") {
return false;
}
break;
} else if (arguments.type == "before" && $performedRenderOrRedirect()) {
} else if (LCase(arguments.type) == "before" && $performedRenderOrRedirect()) {
break;
} else if (arguments.type == "after" && $performedRedirect()) {
} else if (LCase(arguments.type) == "after" && $performedRedirect()) {
break;
}
}
}
return true;
}
}
92 changes: 62 additions & 30 deletions vendor/wheels/controller/processing.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,18 @@ component {
// Continue unless an abort is issued from a verification.
if (!$abortIssued()) {
// Run before filters if they exist on the controller.
local.runAction = true;
if (ListFindNoCase("true,before", arguments.includeFilters)) {
$runFilters(type = "before", action = variables.params.action);
local.runAction = $runFilters(type = "before", action = variables.params.action);
}

if ($get("showDebugInformation")) {
$debugPoint("beforeFilters,action");
}

// Only proceed to call the action if the before filter has not already rendered content.
if (!$performedRenderOrRedirect()) {
// 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";
Expand All @@ -60,33 +62,21 @@ component {
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)) {
for (local.item in ListToArray(local.appendToKey)) {
if (IsDefined(local.item)) {
// Build the scope lookup once (and keep it in the local scope so it doesn't leak into the controller's variables scope).
if (!StructKeyExists(local, "scopeMap")) {
local.scopeMap = {
"request": request,
"arguments": arguments,
"application": application,
"session": session,
"variables": variables
};
}

// Extract scope name and variable name from local.item
local.scopeName = ListFirst(local.item, ".");
local.varName = ListLast(local.item, ".");
if (
StructKeyExists(local.scopeMap, local.scopeName)
&& StructKeyExists(local.scopeMap[local.scopeName], local.varName)
) {
local.key &= local.scopeMap[local.scopeName][local.varName];
} else {
Throw(type = "Wheels.KeyNotFound", message = "The `#local.item#` argument was not found.");
}
}
}
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 = {};
Expand Down Expand Up @@ -119,7 +109,7 @@ component {
$debugPoint("action,afterFilters");
}

if (!$performedRedirect() && ListFindNoCase("true,after", arguments.includeFilters)) {
if (local.runAction && !$performedRedirect() && ListFindNoCase("true,after", arguments.includeFilters)) {
$runFilters(type = "after", action = variables.params.action);
}

Expand Down Expand Up @@ -245,4 +235,46 @@ component {
);
return response();
}

/**
* Internal function. Appends resolved appendToKey segments onto an action cache key.
* Every listed item must resolve; silent omission would share one key across users.
*/
public string function $appendToCacheKey(required string key, required string appendToKey, required struct scopeMap) {
local.rv = arguments.key;
local.items = ListToArray(arguments.appendToKey);
local.iEnd = ArrayLen(local.items);
for (local.i = 1; local.i <= local.iEnd; local.i++) {
local.rv &= $resolveAppendToKeyValue(item = local.items[local.i], scopeMap = arguments.scopeMap);
}
return local.rv;
}

/**
* Internal function. Walks a dotted appendToKey path (scope.a.b.c) and returns
* the simple value. Throws Wheels.KeyNotFound when any segment is missing.
*/
public string function $resolveAppendToKeyValue(required string item, required struct scopeMap) {
local.segments = ListToArray(arguments.item, ".");
if (ArrayLen(local.segments) < 2) {
Throw(type = "Wheels.KeyNotFound", message = "The `#arguments.item#` argument was not found.");
}
local.scopeName = local.segments[1];
if (!StructKeyExists(arguments.scopeMap, local.scopeName)) {
Throw(type = "Wheels.KeyNotFound", message = "The `#arguments.item#` argument was not found.");
}
local.cursor = arguments.scopeMap[local.scopeName];
local.iEnd = ArrayLen(local.segments);
for (local.i = 2; local.i <= local.iEnd; local.i++) {
local.segment = local.segments[local.i];
if (!IsStruct(local.cursor) || !StructKeyExists(local.cursor, local.segment)) {
Throw(type = "Wheels.KeyNotFound", message = "The `#arguments.item#` argument was not found.");
}
local.cursor = local.cursor[local.segment];
}
if (IsNull(local.cursor) || !IsSimpleValue(local.cursor)) {
Throw(type = "Wheels.KeyNotFound", message = "The `#arguments.item#` argument was not found.");
}
return ToString(local.cursor);
}
}
7 changes: 4 additions & 3 deletions vendor/wheels/events/init/security.cfm
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,10 @@
application.$wheels.debugAccessTrustProxy = false;

// Trusted proxy settings.
// Only when true are X-Forwarded-* headers honored framework-wide: X-Forwarded-Proto in
// isSecure(), and X-Forwarded-For (rightmost hop) for maintenance-mode IP exceptions and
// reload rate-limit keying. Leave false unless the app sits behind a trusted reverse proxy
// Only when true are proxy-supplied headers honored framework-wide: X-Forwarded-Proto in
// isSecure(), X-Forwarded-For (rightmost hop) for maintenance-mode IP exceptions and
// reload rate-limit keying, and X-Rewrite-URL / X-Original-URL when $cgiScope() fills a
// blank path_info (IIS). Leave false unless the app sits behind a trusted reverse proxy
// that overwrites — never appends to — these headers.
application.$wheels.trustProxyHeaders = false;

Expand Down
17 changes: 10 additions & 7 deletions vendor/wheels/global/request.cfm
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,12 @@

// fixes IIS issue that returns a blank cgi.path_info
if (!Len(local.rv.path_info) && Right(local.rv.script_name, 10) == "/index.cfm") {
if (Len(local.rv.http_x_rewrite_url)) {
// IIS6 1/ IIRF (Ionics Isapi Rewrite Filter)
if ($trustProxyHeaders() && Len(local.rv.http_x_rewrite_url)) {
// IIS6 1/ IIRF (Ionics Isapi Rewrite Filter). Client-supplied;
// only trusted when the app opted in via trustProxyHeaders.
local.rv.path_info = ListFirst(local.rv.http_x_rewrite_url, "?");
} else if (Len(local.rv.http_x_original_url)) {
// IIS7 rewrite default
} else if ($trustProxyHeaders() && Len(local.rv.http_x_original_url)) {
// IIS7 rewrite default. Same trust gate as X-Forwarded-*.
local.rv.path_info = ListFirst(local.rv.http_x_original_url, "?");
} else if (Len(local.rv.request_uri)) {
// Apache default
Expand Down Expand Up @@ -160,9 +161,11 @@


/**
* Internal function. Returns whether the application has opted into trusting `X-Forwarded-*`
* headers via `set(trustProxyHeaders=true)`. Guarded so it is safe to call on a cold start
* before `application.wheels` exists (resolves to `false`, i.e. do not trust).
* Internal function. Returns whether the application has opted into trusting
* proxy-supplied headers via `set(trustProxyHeaders=true)`: `X-Forwarded-*`
* plus the IIS rewrite headers `X-Rewrite-URL` / `X-Original-URL` used by
* `$cgiScope()`. Guarded so it is safe to call on a cold start before
* `application.wheels` exists (resolves to `false`, i.e. do not trust).
*/
public boolean function $trustProxyHeaders() {
return StructKeyExists(application, "wheels")
Expand Down
34 changes: 34 additions & 0 deletions vendor/wheels/tests/_assets/controllers/HardenerLifecycle.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
component extends="Controller" {

function config() {
filters(through = "denyUnlessAllowed", only = "secret");
filters(through = "denyCased", only = "casedAction", type = "Before");
}

function secret() {
request.hardenerSecretRan = true;
renderText("secret-ok");
}

function casedAction() {
request.hardenerCasedRan = true;
renderText("cased-ok");
}

function cachedShow() {
renderText(request.hardenerCachePayload);
}

private function denyUnlessAllowed() {
request.hardenerDenyRan = true;
if (!StructKeyExists(request, "hardenerAllow") || !request.hardenerAllow) {
return false;
}
}

private function denyCased() {
request.hardenerCasedFilterRan = true;
return false;
}

}
8 changes: 8 additions & 0 deletions vendor/wheels/tests/specs/dispatch/createParamsSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,11 @@ component extends="wheels.WheelsTest" {
})

it("sets controller in upper camel case", () => {
// Wildcard-style route: no fixed controller, so the incoming
// name is the value that gets camelized (B1: a routed
// controller name is no longer overridable from the form).
args.route.pattern = "/[controller]"
StructDelete(args.route, "controller")
args.formScope["controller"] = "wheels-test"
_params = dispatch.$createParams(argumentCollection = args)

Expand All @@ -165,6 +170,9 @@ component extends="wheels.WheelsTest" {
})

it("sanitizes controller and action params", () => {
args.route.pattern = "/[controller]/[action]"
StructDelete(args.route, "controller")
StructDelete(args.route, "action")
args.formScope["controller"] = "../../../wheels%00"
args.formScope["action"] = "../../../test*^&%()%00"
_params = dispatch.$createParams(argumentCollection = args)
Expand Down
15 changes: 15 additions & 0 deletions vendor/wheels/tests/specs/global/internalSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ component extends="wheels.WheelsTest" {
describe("Tests that $cgiscope", () => {

beforeEach(() => {
_hadTrustProxyHeaders = StructKeyExists(application.wheels, "trustProxyHeaders")
if (_hadTrustProxyHeaders) {
_originalTrustProxyHeaders = application.wheels.trustProxyHeaders
}
// These cases document the IIS rewrite-header recovery order.
// The headers are client-supplied and require trustProxyHeaders.
application.wheels.trustProxyHeaders = true
cgi_scope = {}
cgi_scope.request_method = ""
cgi_scope.http_x_requested_with = ""
Expand All @@ -88,6 +95,14 @@ component extends="wheels.WheelsTest" {
cgi_scope.http_x_forwarded_proto = ""
})

afterEach(() => {
if (_hadTrustProxyHeaders) {
application.wheels.trustProxyHeaders = _originalTrustProxyHeaders
} else {
StructDelete(application.wheels, "trustProxyHeaders")
}
})

it("checks path info is blank", () => {
cgi_scope.path_info = ""
_cgi = g.$cgiScope(scope = cgi_scope)
Expand Down
Loading
Loading