You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
CSRF default-on decision (5.0): flip the default after the detect-and-warn deprecation window #2960
CSRF protection is opt-in and silently disabled by the documented config() override footgun. vendor/wheels/controller/csrf.cfc:18-20, 37-51 no-ops unless StructKeyExists(variables.$class, "csrf"); CSRF is wired only via the generated base controller (cli/lucli/templates/app/app/controllers/Controller.cfc:23-25), which itself doesn't call super.config(). Any child controller overriding config() without super.config() silently disables CSRF for that controller, with no error or startup warning. Verified against origin/develop @ 8971094.
This is a recurring footgun (tracked around #2845; see also project memory on the super.config() chain fragility). Prior remediation chose docs + generator templates that call super.config().
Impact
A developer who overrides config() in a controller — a common, documented pattern — without calling super.config() silently ships that controller with CSRF disabled and no warning. The protection's presence is invisible and easy to drop.
Suggested approach
This is a deliberate behavior change for every existing app, so it needs an upgrade-path design rather than an autonomous flip:
Either enforce CSRF at the dispatch/middleware layer (independent of per-controller config()), or
Detect-and-warn at startup when a controller overrides config() without calling super.config().
If making CSRF default-on, design a deprecation window / upgrade note so existing apps are not broken silently.
Acceptance criteria
A controller overriding config() without super.config() either keeps CSRF active or emits a clear startup/dev warning.
If the default flips on, an upgrade path and wheels upgrade check advisory exist.
Specs cover the override-without-super.config() case.
Classified as framework-design (API/feature design question).
Cross-framework research will follow shortly — Rails, Laravel, Django,
and other modern frameworks will be consulted before any path forward
is proposed.
Wheels currently enables CSRF protection via protectsFromForgery() called inside a controller's config() lifecycle hook (vendor/wheels/controller/csrf.cfc:18-20, 37-51). $runCsrfProtection() no-ops unless variables.$class.csrf exists, and the only place it is set is the generated base Controller.cfc. A child controller that overrides config() without super.config() silently disables CSRF for itself — no warning, no startup error. The design question is whether to (a) make CSRF default-on at the dispatch/middleware layer so it cannot be silently dropped, (b) detect-and-warn at startup when config() is overridden without super, or (c) keep status quo with better docs, and what the upgrade path looks like for existing apps.
How other frameworks handle this
Framework
Naming
Surface
Trade-offs
Edge cases
Rails
protect_from_forgery, skip_forgery_protection
Inherited before_action on ActionController::Base since 5.2 (load_defaults)
No super footgun — callback lives on the parent class, not in a user-overridable lifecycle method
ActionController::API excludes the mixin; load_defaults is per-version upgrade gate
Every framework surveyed defaults CSRF on at the framework layer — middleware (Django, Laravel, Spring), router-pipeline plug (Phoenix), parent-class inherited callback (Rails 5.2+), or config-layer plus declarative attribute (Symfony). None puts the registration of CSRF inside a per-controller config() hook the user is free to override, precisely because that pattern has a long history of silent footguns. Granular opt-out is universally declarative — a decorator/attribute, a URI-pattern list, or routing through a different pipeline — never "forget to call super." The lone divergence is where the layer lives: Rails ties it to the controller base class via inherited callbacks (still inheritance, but immune to overriding a single lifecycle method because the registration is at class-load time, not call time), while everyone else moves it to a dispatch-/middleware-layer filter that runs regardless of controller code. Both eliminate the footgun.
Recommended path for Wheels
Option (a) is the cross-framework consensus and aligns with Wheels' existing middleware-and-defaults architecture. Move the registration of CSRF out of user-overridable config() and into a framework default (mirroring how application.$wheels.csrfStore, csrfCookieSameSite, etc. are seeded in events/init/security.cfm). Keep $runCsrfProtection() as the dispatch-level enforcement hook (it already runs from processAction(), which subclasses cannot override away). Add a Rails-style skipForgeryProtection() controller method as the declarative opt-out. The footgun disappears because protection is no longer contingent on the child calling super.config() — it is the framework's default, switchable only via an explicit per-controller skipForgeryProtection() call or an app-wide set(csrfDefaultProtected=false) in config/settings.cfm.
// vendor/wheels/events/init/security.cfm — new framework defaults
application.$wheels.csrfDefaultProtected = true; // 5.0 default; 4.x.x patch ships false
application.$wheels.csrfDefaultWith = "exception"; // exception | abort | ignore
// vendor/wheels/controller/csrf.cfc — modified $runCsrfProtection
public function $runCsrfProtection(string action) {
if (StructKeyExists(variables.$class, "csrfSkip")
&& $appliesToAction(action = arguments.action,
only = variables.$class.csrfSkip.only,
except = variables.$class.csrfSkip.except)) {
return;
}
if (StructKeyExists(variables.$class, "csrf")) {
local.csrf = variables.$class.csrf;
} else if (application.wheels.csrfDefaultProtected) {
local.csrf = {
type = application.wheels.csrfDefaultWith,
only = "",
except = ""
};
} else {
return;
}
if ($appliesToAction(action = arguments.action, only = local.csrf.only, except = local.csrf.except)) {
$storeAuthenticityToken();
$flagRequestAsProtected();
$setAuthenticityToken();
$verifyAuthenticityToken();
}
}
// New controller method — declarative opt-out (Rails parity)
public function skipForgeryProtection(string only = "", string except = "") {
variables.$class.csrfSkip = {only = arguments.only, except = arguments.except};
}
Usage:
// API-only controller — declarative opt-out
component extends="Controller" {
function config() {
skipForgeryProtection(); // no super.config() needed; framework default still applies elsewhere
}
}
// Webhook endpoint — opt out for one action
component extends="Controller" {
function config() {
super.config();
skipForgeryProtection(only="stripeCallback");
}
}
// App-wide opt-out (rare; legacy compatibility) in config/settings.cfm:
set(csrfDefaultProtected=false);
Lives in: vendor/wheels/controller/csrf.cfc (modified $runCsrfProtection, new skipForgeryProtection) and vendor/wheels/events/init/security.cfm (new defaults). The generated cli/lucli/templates/app/app/controllers/Controller.cfc keeps its explicit protectsFromForgery() call for discoverability under 4.x; it becomes redundant-but-harmless under 5.0.
Composes with: existing $appliesToAction() helper, existing application.wheels.* settings convention in events/init/security.cfm, existing wheels upgrade check advisory surface for the 5.0 default flip, and existing protectsFromForgery() (left intact for per-controller overrides of with=/only=/except=).
Cross-engine notes
Clean. The change relies only on StructKeyExists / struct literal assignment / application.wheels.* reads — all engine-safe on Lucee 5/6/7, Adobe 2018/2021/2023/2025, and BoxLang. No closures, no obj.map(), no array-by-value, no application scope function members, no local.X inside catch/finally. variables.$class is the existing per-controller-class state surface used throughout the framework. The new skipForgeryProtection(only="", except="") uses all-named arguments — no mixed positional/named anti-pattern.
Open questions
Default-flip version: ship the new default-on behavior as a Wheels 5.0 breaking change (cleanest), or behind a set(csrfDefaultProtected=true) opt-in for the entire 4.x line with a deprecation warning when the flag is unset (safer upgrade)? Rails took the latter route with load_defaults.
wheels upgrade check advisory: should the check warn about controllers that override config() without super.config() regardless of the default flip — the override-without-super pattern itself can drop other base-controller setup beyond CSRF (filters, verifies, DI injection)?
API-controller convention: should the wheels new --api (or equivalent) template default to a base controller that calls skipForgeryProtection(), matching Rails' ActionController::API separation? Or is per-controller opt-out the right granularity?
Detection vs prevention: drop option (b) entirely (cross-framework consensus says prevention wins), or keep it as a wheels doctor advisory on top of option (a)?
Confidence: medium
The dominant pattern is unambiguous (default-on at the framework layer, not inside per-controller config()), and the CFML sketch is clean — but the issue itself flags this as a deliberate behavior change requiring upgrade-path design, the +1 framework (Symfony) diverges non-trivially by pushing CSRF into the Form component rather than middleware, and the 4.x-vs-5.0 default-flip timing is a real design judgement call worth a human weigh-in before code lands.
The proposed fix touches a sensitive area (CSRF token / auth code in vendor/wheels/controller/csrf.cfc and security defaults in vendor/wheels/events/init/security.cfm) and the bot's safety net requires a human in the loop before any code is written.
Additionally, the cross-framework research (wheels-bot:research:2960) explicitly flags this as a deliberate behavior change with open design questions a human should decide before implementation:
Default-flip version: 5.0 breaking change vs. opt-in flag through 4.x with deprecation warning (Rails load_defaults style)?
wheels upgrade check advisory: should it warn on every config() override missing super.config(), not just CSRF?
API-controller convention: should wheels new --api templates default to skipForgeryProtection() (Rails ActionController::API parity)?
Detection vs prevention: drop the warn-on-missing-super path entirely, or keep it as a wheels doctor advisory on top of the default-on change?
Research confidence is medium and the issue body itself states "needs an upgrade-path design rather than an autonomous flip" — both signals point at a human-driven design pass before any code lands.
changed the title [-]review remediation: CSRF default-on / super.config() override detection (config() footgun silently disables CSRF)[/-][+]CSRF default-on decision (5.0): flip the default after the detect-and-warn deprecation window[/+]on Jun 12, 2026
Maintainer call (2026-09-08): Remains parked for 5.0. No work this week (including no 4.x doctor/startup warn). Revisit when 5.0 packaging/shape is clearer.
Activity
wheels-bot commentedon Jun 10, 2026
Wheels Bot — Triage
Classified as framework-design (API/feature design question).
Cross-framework research will follow shortly — Rails, Laravel, Django,
and other modern frameworks will be consulted before any path forward
is proposed.
<!-- wheels-bot:triage:2960 -->
<!-- wheels-bot:triage-class:framework-design -->
wheels-bot commentedon Jun 10, 2026
Wheels Bot — Cross-Framework Research
Question
Wheels currently enables CSRF protection via
protectsFromForgery()called inside a controller'sconfig()lifecycle hook (vendor/wheels/controller/csrf.cfc:18-20, 37-51).$runCsrfProtection()no-ops unlessvariables.$class.csrfexists, and the only place it is set is the generated baseController.cfc. A child controller that overridesconfig()withoutsuper.config()silently disables CSRF for itself — no warning, no startup error. The design question is whether to (a) make CSRF default-on at the dispatch/middleware layer so it cannot be silently dropped, (b) detect-and-warn at startup whenconfig()is overridden withoutsuper, or (c) keep status quo with better docs, and what the upgrade path looks like for existing apps.How other frameworks handle this
protect_from_forgery,skip_forgery_protectionbefore_actiononActionController::Basesince 5.2 (load_defaults)superfootgun — callback lives on the parent class, not in a user-overridable lifecycle methodActionController::APIexcludes the mixin;load_defaultsis per-version upgrade gateValidateCsrfTokenmiddleware,@csrf,validateCsrfTokens(except:)webgroup;apigroup omits it by constructionexcept:[]; SPAs use Sanctum;XSRF-TOKENcookie for AJAXCsrfViewMiddleware,@csrf_exempt,{% csrf_token %}startproject; per-view opt-out via decorator attributecsrf_exempt=Trueon the callableprotect_from_forgeryplug,:browserpipeline:browserpipeline;:apipipeline omits itrouter.ex, not inheritedCsrfFilter,HttpSecurity.csrf()DSLSecurityFilterChain@Bean, not the controllercsrf.disable()for stateless APIs;ignoringRequestMatcherspreferred over full disable#[IsCsrfTokenValid]attribute,framework.csrf_protectionconfig, Form component auto-token'csrf_protection' => false; stateless mode uses Origin/Referer + double-submit cookieDominant pattern
Every framework surveyed defaults CSRF on at the framework layer — middleware (Django, Laravel, Spring), router-pipeline plug (Phoenix), parent-class inherited callback (Rails 5.2+), or config-layer plus declarative attribute (Symfony). None puts the registration of CSRF inside a per-controller
config()hook the user is free to override, precisely because that pattern has a long history of silent footguns. Granular opt-out is universally declarative — a decorator/attribute, a URI-pattern list, or routing through a different pipeline — never "forget to call super." The lone divergence is where the layer lives: Rails ties it to the controller base class via inherited callbacks (still inheritance, but immune to overriding a single lifecycle method because the registration is at class-load time, not call time), while everyone else moves it to a dispatch-/middleware-layer filter that runs regardless of controller code. Both eliminate the footgun.Recommended path for Wheels
Option (a) is the cross-framework consensus and aligns with Wheels' existing middleware-and-defaults architecture. Move the registration of CSRF out of user-overridable
config()and into a framework default (mirroring howapplication.$wheels.csrfStore,csrfCookieSameSite, etc. are seeded inevents/init/security.cfm). Keep$runCsrfProtection()as the dispatch-level enforcement hook (it already runs fromprocessAction(), which subclasses cannot override away). Add a Rails-styleskipForgeryProtection()controller method as the declarative opt-out. The footgun disappears because protection is no longer contingent on the child callingsuper.config()— it is the framework's default, switchable only via an explicit per-controllerskipForgeryProtection()call or an app-wideset(csrfDefaultProtected=false)inconfig/settings.cfm.// vendor/wheels/events/init/security.cfm — new framework defaults application.$wheels.csrfDefaultProtected = true; // 5.0 default; 4.x.x patch ships false application.$wheels.csrfDefaultWith = "exception"; // exception | abort | ignore // vendor/wheels/controller/csrf.cfc — modified $runCsrfProtection public function $runCsrfProtection(string action) { if (StructKeyExists(variables.$class, "csrfSkip") && $appliesToAction(action = arguments.action, only = variables.$class.csrfSkip.only, except = variables.$class.csrfSkip.except)) { return; } if (StructKeyExists(variables.$class, "csrf")) { local.csrf = variables.$class.csrf; } else if (application.wheels.csrfDefaultProtected) { local.csrf = { type = application.wheels.csrfDefaultWith, only = "", except = "" }; } else { return; } if ($appliesToAction(action = arguments.action, only = local.csrf.only, except = local.csrf.except)) { $storeAuthenticityToken(); $flagRequestAsProtected(); $setAuthenticityToken(); $verifyAuthenticityToken(); } } // New controller method — declarative opt-out (Rails parity) public function skipForgeryProtection(string only = "", string except = "") { variables.$class.csrfSkip = {only = arguments.only, except = arguments.except}; }Usage:
// API-only controller — declarative opt-out component extends="Controller" { function config() { skipForgeryProtection(); // no super.config() needed; framework default still applies elsewhere } } // Webhook endpoint — opt out for one action component extends="Controller" { function config() { super.config(); skipForgeryProtection(only="stripeCallback"); } } // App-wide opt-out (rare; legacy compatibility) in config/settings.cfm: set(csrfDefaultProtected=false);Lives in:
vendor/wheels/controller/csrf.cfc(modified$runCsrfProtection, newskipForgeryProtection) andvendor/wheels/events/init/security.cfm(new defaults). The generatedcli/lucli/templates/app/app/controllers/Controller.cfckeeps its explicitprotectsFromForgery()call for discoverability under 4.x; it becomes redundant-but-harmless under 5.0.Composes with: existing
$appliesToAction()helper, existingapplication.wheels.*settings convention inevents/init/security.cfm, existingwheels upgrade checkadvisory surface for the 5.0 default flip, and existingprotectsFromForgery()(left intact for per-controller overrides ofwith=/only=/except=).Cross-engine notes
Clean. The change relies only on
StructKeyExists/ struct literal assignment /application.wheels.*reads — all engine-safe on Lucee 5/6/7, Adobe 2018/2021/2023/2025, and BoxLang. No closures, noobj.map(), no array-by-value, noapplicationscope function members, nolocal.Xinsidecatch/finally.variables.$classis the existing per-controller-class state surface used throughout the framework. The newskipForgeryProtection(only="", except="")uses all-named arguments — no mixed positional/named anti-pattern.Open questions
set(csrfDefaultProtected=true)opt-in for the entire 4.x line with a deprecation warning when the flag is unset (safer upgrade)? Rails took the latter route withload_defaults.wheels upgrade checkadvisory: should the check warn about controllers that overrideconfig()withoutsuper.config()regardless of the default flip — the override-without-super pattern itself can drop other base-controller setup beyond CSRF (filters, verifies, DI injection)?wheels new --api(or equivalent) template default to a base controller that callsskipForgeryProtection(), matching Rails'ActionController::APIseparation? Or is per-controller opt-out the right granularity?wheels doctoradvisory on top of option (a)?Confidence:
mediumThe dominant pattern is unambiguous (default-on at the framework layer, not inside per-controller
config()), and the CFML sketch is clean — but the issue itself flags this as a deliberate behavior change requiring upgrade-path design, the +1 framework (Symfony) diverges non-trivially by pushing CSRF into the Form component rather than middleware, and the 4.x-vs-5.0 default-flip timing is a real design judgement call worth a human weigh-in before code lands.Sources
wheels-bot commentedon Jun 10, 2026
Wheels Bot — Fix on hold for human review
The proposed fix touches a sensitive area (CSRF token / auth code in
vendor/wheels/controller/csrf.cfcand security defaults invendor/wheels/events/init/security.cfm) and the bot's safety net requires a human in the loop before any code is written.Additionally, the cross-framework research (
wheels-bot:research:2960) explicitly flags this as a deliberate behavior change with open design questions a human should decide before implementation:load_defaultsstyle)?wheels upgrade checkadvisory: should it warn on everyconfig()override missingsuper.config(), not just CSRF?wheels new --apitemplates default toskipForgeryProtection()(RailsActionController::APIparity)?wheels doctoradvisory on top of the default-on change?Research confidence is
mediumand the issue body itself states "needs an upgrade-path design rather than an autonomous flip" — both signals point at a human-driven design pass before any code lands.feat(controller): dev-mode warning when config() override skips super…
[-]review remediation: CSRF default-on / super.config() override detection (config() footgun silently disables CSRF)[/-][+]CSRF default-on decision (5.0): flip the default after the detect-and-warn deprecation window[/+]bpamiri commentedon Sep 8, 2026
Maintainer call (2026-09-08): Remains parked for 5.0. No work this week (including no 4.x doctor/startup warn). Revisit when 5.0 packaging/shape is clearer.