Skip to content

Dev-mode inline notice when ?reload=true is refused (empty reloadPassword / missing or wrong password) #3311

Closed

Description

@bpamiri

Problem

Since the fail-closed reload gate shipped in 4.0.4 (#3062, PR #3140), hitting ?reload=true while URL reload is disabled — most commonly set(reloadPassword="") — is a complete silent no-op in the browser. The request serves normally with zero indication that a reload was requested and refused.

The only feedback channels today are:

  • the boot-time warning in wheels_security.log (vendor/wheels/events/onapplicationstart.cfc:368-371)
  • the per-attempt accept/reject lines in the same log
  • the debug-footer warning when reloadPassword is empty (vendor/wheels/events/onrequestend/debug.cfm:146)

None of these are in the developer's face at the moment they hit ?reload=true and nothing happens. Real-world impact: this has now produced at least two support reports shaped as "reload is broken, I have to restart the server" from long-time users whose v1–v3 muscle memory is ?reload=true with an empty password. The most recent (2026-07-14, community Slack): app configured with set(reloadPassword=""), user concluded reload no longer works in v4.

The fail-closed behavior itself is correct and should not change. The gap is purely discoverability at request time.

Proposal

In the development environment only, when a request arrives with url.reload present but the reload gate refuses to fire, surface an inline, browser-visible notice explaining why and what to do.

Cases to cover (all dev-only):

  1. reloadPassword is empty — the headline case. Message along the lines of:

    Reload requested but URL-based reload is disabled because reloadPassword is empty. Set set(reloadPassword=env("WHEELS_RELOAD_PASSWORD", "")) in config/settings.cfm and pass &password=.... (Fail-closed since 4.0.4, Reload-password contract drift: empty password leaves ?reload=true open to anonymous restarts, warm-app wrong-password attempts are never logged or rate-limited, and the boot warning misstates behavior #3062.)

  2. password parameter missing while a password is configured — "add &password=...".
  3. Wrong password / rate-limited — a generic "reload refused; see wheels_security.log" notice. Must NOT distinguish wrong-password from rate-limited and must not echo anything about the configured password (keep the constant-time/no-oracle properties of the gate; in dev this is low-stakes, but parity is cheap).

Non-development environments: behavior unchanged — silent no-op + security log, exactly as today. No new response-surface in testing/production/maintenance.

Implementation sketch (open to alternatives)

The gate lives in app-template code (public/Application.cfc, 4 copies, pinned by the structural parity spec), but the notice logic should live in the framework so it's maintained in one place. Options, roughly in order of preference:

  • Debug-bar notice: the gate sets a request.wheels.reloadRefusedReason flag; events/onrequestend/debug.cfm renders a prominent banner when present. Zero new output path — rides the existing debug footer, which is already dev-only by default.
  • Flash-style banner injected into the rendered page independent of the debug bar (covers apps that turn showDebugInformation off in dev).
  • A dev-only Wheels.ReloadDisabled friendly error page instead of serving the request. Most visible, but interrupts the request and may be too aggressive for case 3.

Whichever shape: template public/Application.cfc copies only need to record the refusal reason; rendering stays framework-side. The parity spec should pin the new flag-setting lines across all four copies.

Acceptance criteria

  • Dev environment + ?reload=true + empty reloadPassword → browser-visible notice naming the setting and the fix; app serves the page normally otherwise
  • Dev environment + configured password + missing password param → notice saying the param is required
  • Dev environment + wrong password or rate-limited → single generic refusal notice (no oracle)
  • testing/production/maintenance → no behavioral change, no new output
  • All four public/Application.cfc copies updated; structural parity spec extended
  • Guides touched: upgrading/3x-to-4x troubleshooting entry updated to mention the new notice

References

Activity

  1. wheels-bot commented on Jul 14, 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.

  2. wheels-bot commented on Jul 14, 2026

    @wheels-bot
    Contributor

    Wheels Bot — Cross-Framework Research

    Question

    Since the fail-closed reload gate shipped (#3062, 4.0.4), ?reload=true when URL reload is disabled — most often an empty reloadPassword — is a silent browser no-op: the page serves normally with no sign the reload was requested and refused. Feedback lives only in wheels_security.log and the debug footer. The ask: in development only, show an inline, browser-visible, actionable notice at request time saying why it was refused and what to do — for (1) empty reloadPassword, (2) missing password param while one is configured, (3) wrong-password / rate-limited (generic, no oracle) — while testing/production/maintenance stay a silent no-op. The fail-closed behavior stays; only request-time discoverability is missing.

    How other frameworks handle this

    Framework Naming Surface Trade-offs Edge cases
    Rails "Actionable Errors" (action DSL); consider_all_requests_local Fix-it buttons on the dev error page (pending-migrations is canonical) Must raise; no passive banner on a 200; gating implicit Buttons inert unless detailed-exceptions on
    Laravel Ignition Solution/ProvidesSolution; config('app.debug') Exception page w/ solution title + "what to do" + docs links; Debugbar overlay Exception-driven; app.debug is binary; dev-only deps APP_DEBUG=true in prod = RCE/leak (CVE-2021-3129)
    Django DEBUG; System Check Framework; messages Technical 500/404 page; startup checks w/ hint; debug-toolbar before </body> Single global DEBUG; checks are startup not per-request Toolbar hidden if IP not in INTERNAL_IPS
    Phoenix debug_errors/code_reloader (dev.exs); Plug.Exception.actions/1; put_flash Plug.Debugger page w/ action buttons; or flash for handled refusals raise=page, return=flash; config-gated, prod-silent Debugger re-raises; CSP stripped; flash needs session
    Spring Boot Whitelabel + server.error.include-* (NEVER/ALWAYS/ON_PARAM); FailureAnalyzer Error detail gated by profile; Action: line in analysis Fail-closed by default, but only fires on a thrown error FailureAnalyzer is startup/console, not per-request browser
    Symfony (+1) Web Debug Toolbar; when@dev; addFlash Toolbar auto-injected before </body>, HTML-only, non-redirect Passive (not actionable alone); dev-gate at registration time Skips redirect/JSON → header-only fallback

    Why Symfony as +1: its Web Debug Toolbar is the canonical "dev-only banner injected before </body>, HTML-only, absent in prod by construction" — the exact shape this issue proposes.

    Dominant pattern

    All six gate dev diagnostics on an explicit dev/debug flag and stay silent (fail-closed) in production by default. They split into (a) exception-driven actionable pages (Rails/Laravel/Phoenix/Spring — must throw, interrupt the request) and (b) passive banners injected into the HTML response in dev (Symfony toolbar, django-debug-toolbar, Laravel Debugbar). They converge on: env-gated, HTML-only, a "why + what to do" string, nothing leaked in prod. The divergence — interrupt vs. passive — is decided by this issue's shape: a refused ?reload= still returns HTTP 200, and Spring/Rails both note their error machinery never fires without a thrown error. So archetype (a) doesn't fit a silent-success refusal — (b), the passive injected banner, is the match, and it respects the issue's note that a friendly-error interstitial (option 3) is "too aggressive for case 3."

    Recommended path for Wheels

    Passive banner (Symfony/Debugbar shape), split into a dumb template side and a framework-side renderer — the issue's preferred option. The four public/Application.cfc copies only record a category on the request scope (never the password; wrong-password folds into refused alongside rate-limited to keep the #3062 no-oracle property). The framework renders a development-only banner at request-end, independent of showDebugInformation so it still shows when an app disables the debug bar in dev (the gap in option 1), reusing onrequestend/debug.cfm's existing AJAX/HTMX/Turbo/JSON skip guards. Shown in tag syntax for portability; cfscript is 1:1.

    <!--- public/Application.cfc (all 4 copies): RECORD ONLY, right after the gate --->
    <cfif local.reloadRequested AND NOT local.reloadAuthorized>
        <cfif NOT StructKeyExists(request, "wheels")><cfset request.wheels = StructNew()></cfif>
        <cfif NOT StructKeyExists(application.wheels, "reloadPassword") OR NOT Len(application.wheels.reloadPassword)>
            <cfset request.wheels.reloadRefusedReason = "emptyPassword">
        <cfelseif NOT StructKeyExists(url, "password")>
            <cfset request.wheels.reloadRefusedReason = "missingParam">
        <cfelse>
            <cfset request.wheels.reloadRefusedReason = "refused"><!--- wrong pw OR rate-limited --->
        </cfif>
    </cfif>
    
    <!--- vendor/wheels/events/EventMethods.cfc: resolver (public + $-prefixed; testable; no oracle) --->
    <cffunction name="$reloadRefusedNotice" access="public" returntype="string">
        <cfargument name="reason" type="string" required="true">
        <cfswitch expression="#arguments.reason#">
            <cfcase value="emptyPassword"><cfreturn "Reload disabled: reloadPassword is empty. Set reloadPassword in config/settings.cfm and pass &password=... (fail-closed since 4.0.4, ##3062)."></cfcase>
            <cfcase value="missingParam"><cfreturn "Reload requested but no password parameter supplied. Add &password=... to the URL."></cfcase>
            <cfdefaultcase><cfreturn "Reload refused. See wheels_security.log for details."></cfdefaultcase>
        </cfswitch>
    </cffunction>
    
    <!--- vendor/wheels/events/onrequestend/reloadnotice.cfm: RENDER (dev-only, debug-bar-independent) --->
    <cfif $get("environment") IS "development" AND StructKeyExists(request, "wheels") AND StructKeyExists(request.wheels, "reloadRefusedReason")>
        <cfoutput><div role="alert" style="position:fixed;top:0;left:0;right:0;z-index:2147483647;background:##f9e2af;color:##1e1e2e;padding:10px 14px;font:13px/1.4 monospace;border-bottom:2px solid ##f5c211;"><strong>Wheels — reload refused:</strong> ##EncodeForHTML($reloadRefusedNotice(request.wheels.reloadRefusedReason))##</div></cfoutput>
    </cfif>

    Lives in: vendor/wheels/events/onrequestend/reloadnotice.cfm (render) + vendor/wheels/events/EventMethods.cfc ($reloadRefusedNotice()); the four Application.cfc copies (public/, cli/lucli/templates/app/public/, examples/starter-app/public/, examples/tweet/public/) record the flag only.
    Composes with: the dev debug footer (onrequestend/debug.cfm, same hook + skip guards), the fail-closed gate (#3062), $get("environment"), and ReloadPasswordGateParitySpec (extend it to pin the four flag-recording blocks).

    Cross-engine notes

    Clean. Only a request-scope struct flag, a cfswitch, and a .cfm include via cfoutput/EncodeForHTML — no closures, no struct member functions, no application-scope function members, no arguments-as-attributeCollection tag wrappers (invariant #10), no reserved-scope shadowing. $reloadRefusedNotice() is public + $-prefixed so it integrates on Lucee/Adobe/BoxLang (invariant #7). Hex colors and interpolations are ##-escaped for .cfm. No Lucee-only/Adobe-only APIs.

    Open questions

    • Which output path? Riding the debug footer (option 1) adds no new output but misses debug-bar-off apps; the standalone partial (above) covers them but adds a small second request-end output path. Recommend the standalone partial — confirm acceptable.
    • Duplication: if both the footer and this banner render, should one defer to the other?
    • Redirect/non-HTML: like Symfony's toolbar, skip those (matching current debug.cfm) — OK?

    Confidence: medium

    Downgraded from high: the modern consensus favors exception-driven actionable pages (Rails/Laravel/Phoenix/Spring), which don't fit a silent HTTP-200 refusal, so I adapted toward the passive-banner minority pattern (Symfony/Debugbar); and the recommendation adds a small new request-end render path (a non-trivial "which output path" trade-off, flagged above). The CFML sketch respects every Wheels convention with no cross-engine concerns.

    Sources

  3. wheels-bot commented on Jul 14, 2026

    @wheels-bot
    Contributor

    Wheels Bot — Fix on hold for human review

    The proposed fix touches a sensitive area (the fail-closed reload-password gate in public/Application.cfc and its three template copies — password/security-gate code) and the bot's safety net requires a human in the loop before any code is written.

    Specifically, the researched path records a refusal category (emptyPassword / missingParam / refused) inside the reload gate, and the correctness of that categorization is security-relevant: it must preserve the #3062 no-oracle property (wrong-password and rate-limited must fold into a single indistinguishable refused state). Newly-written logic in that area — plus the still-open design questions flagged in the medium-confidence research comment (which output path; footer-vs-banner de-duplication; redirect/non-HTML handling) — should be authored and reviewed by a human rather than auto-proposed by the bot.

    The framework-side pieces (the dev-only onrequestend banner render and the $reloadRefusedNotice() string resolver) are low-risk on their own, but they are inseparable from the gate-side flag recording, so the whole change is held together.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions