Skip to content

CSRF default-on decision (5.0): flip the default after the detect-and-warn deprecation window #2960

Closed as not planned

Description

@bpamiri

Problem

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.

Source

Internal multi-agent framework review 2026-06-09, wave 2 (issues phase). Findings: security-pass SEC-10 (S5), followups SEC-10.

Activity

  1. wheels-bot commented on Jun 10, 2026

    @wheels-bot
    Contributor

    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 -->

  2. wheels-bot commented on Jun 10, 2026

    @wheels-bot
    Contributor

    Wheels Bot — Cross-Framework Research

    Question

    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
    Laravel ValidateCsrfToken middleware, @csrf, validateCsrfTokens(except:) Middleware in web group; api group omits it by construction Transport-layer middleware; controller subclassing cannot disable it URI-pattern exemption via except:[]; SPAs use Sanctum; XSRF-TOKEN cookie for AJAX
    Django CsrfViewMiddleware, @csrf_exempt, {% csrf_token %} Middleware registered by startproject; per-view opt-out via decorator attribute "Secure by default, explicit to disable" — decorator stamps csrf_exempt=True on the callable DRF token auth auto-exempt; no startup warning if middleware removed
    Phoenix protect_from_forgery plug, :browser pipeline Plug in :browser pipeline; :api pipeline omits it Explicit and grep-able — security posture lives in router.ex, not inherited Per-route opt-out via a different pipeline, not a per-controller toggle
    Spring Security CsrfFilter, HttpSecurity.csrf() DSL Filter at position 5/15 in the default chain, on since 4.x Inheritance-proof — decision lives in SecurityFilterChain @Bean, not the controller csrf.disable() for stateless APIs; ignoringRequestMatchers preferred over full disable
    Symfony (+1: layered, opinionated, Rails-shaped MVC with explicit middleware/config layers mirroring Wheels' middleware + base controller split) #[IsCsrfTokenValid] attribute, framework.csrf_protection config, Form component auto-token Default-on at framework config + Form component; attribute for non-form actions Pushed CSRF out of controllers — into config layer and declarative attributes Per-form opt-out via 'csrf_protection' => false; stateless mode uses Origin/Referer + double-submit cookie

    Dominant 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 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.

    Sources

  3. wheels-bot commented on Jun 10, 2026

    @wheels-bot
    Contributor

    Wheels Bot — Fix on hold for human review

    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.

  4. 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
  5. added
    design-decisionParked pending an explicit maintainer design decision
    v5.0Deferred to the 5.0 major (often breaking)
    on Jun 12, 2026
  6. bpamiri commented on Sep 8, 2026

    @bpamiri
    CollaboratorAuthor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    breakingdesign-decisionParked pending an explicit maintainer design decisionv5.0Deferred to the 5.0 major (often breaking)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions