Repository navigation
Description
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
reloadPasswordis 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):
reloadPasswordis empty — the headline case. Message along the lines of:Reload requested but URL-based reload is disabled because
reloadPasswordis empty. Setset(reloadPassword=env("WHEELS_RELOAD_PASSWORD", ""))inconfig/settings.cfmand pass&password=.... (Fail-closed since 4.0.4, Reload-password contract drift: empty password leaves?reload=trueopen to anonymous restarts, warm-app wrong-password attempts are never logged or rate-limited, and the boot warning misstates behavior #3062.)passwordparameter missing while a password is configured — "add&password=...".- 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.reloadRefusedReasonflag;events/onrequestend/debug.cfmrenders 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
showDebugInformationoff in dev). - A dev-only
Wheels.ReloadDisabledfriendly 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+ emptyreloadPassword→ browser-visible notice naming the setting and the fix; app serves the page normally otherwise - Dev environment + configured password + missing
passwordparam → 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.cfccopies updated; structural parity spec extended - Guides touched: upgrading/3x-to-4x troubleshooting entry updated to mention the new notice
References
- Reload-password contract drift: empty password leaves
?reload=trueopen to anonymous restarts, warm-app wrong-password attempts are never logged or rate-limited, and the boot warning misstates behavior #3062 — fail-closed reload gate (shipped 4.0.4, PR fix(public): empty reloadPassword disables URL reload; log and rate-limit warm-path attempts #3140) public/Application.cfc:277-347— warm-path reload gatevendor/wheels/events/onapplicationstart.cfc:368-371— boot warningvendor/wheels/events/onrequestend/debug.cfm:146— existing empty-password debug-footer warning
Activity
wheels-bot commentedon Jul 14, 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 commentedon Jul 14, 2026
Wheels Bot — Cross-Framework Research
Question
Since the fail-closed reload gate shipped (#3062, 4.0.4),
?reload=truewhen URL reload is disabled — most often an emptyreloadPassword— is a silent browser no-op: the page serves normally with no sign the reload was requested and refused. Feedback lives only inwheels_security.logand 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) emptyreloadPassword, (2) missingpasswordparam 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
actionDSL);consider_all_requests_localraise; no passive banner on a 200; gating implicitSolution/ProvidesSolution;config('app.debug')app.debugis binary; dev-only depsAPP_DEBUG=truein prod = RCE/leak (CVE-2021-3129)DEBUG; System Check Framework;messageshint; debug-toolbar before</body>DEBUG; checks are startup not per-requestINTERNAL_IPSdebug_errors/code_reloader(dev.exs);Plug.Exception.actions/1;put_flashPlug.Debuggerpage w/ action buttons; or flash for handled refusalsserver.error.include-*(NEVER/ALWAYS/ON_PARAM);FailureAnalyzerAction:line in analysisFailureAnalyzeris startup/console, not per-request browserwhen@dev;addFlash</body>, HTML-only, non-redirectWhy 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.cfccopies only record a category on the request scope (never the password; wrong-password folds intorefusedalongside rate-limited to keep the #3062 no-oracle property). The framework renders adevelopment-only banner at request-end, independent ofshowDebugInformationso it still shows when an app disables the debug bar in dev (the gap in option 1), reusingonrequestend/debug.cfm's existing AJAX/HTMX/Turbo/JSON skip guards. Shown in tag syntax for portability; cfscript is 1:1.Lives in:
vendor/wheels/events/onrequestend/reloadnotice.cfm(render) +vendor/wheels/events/EventMethods.cfc($reloadRefusedNotice()); the fourApplication.cfccopies (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"), andReloadPasswordGateParitySpec(extend it to pin the four flag-recording blocks).Cross-engine notes
Clean. Only a request-scope struct flag, a
cfswitch, and a.cfminclude viacfoutput/EncodeForHTML— no closures, no struct member functions, noapplication-scope function members, noarguments-as-attributeCollectiontag wrappers (invariant #10), no reserved-scope shadowing.$reloadRefusedNotice()ispublic+$-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
debug.cfm) — OK?Confidence:
mediumDowngraded 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
wheels-bot commentedon Jul 14, 2026
Wheels Bot — Fix on hold for human review
The proposed fix touches a sensitive area (the fail-closed reload-password gate in
public/Application.cfcand 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 indistinguishablerefusedstate). Newly-written logic in that area — plus the still-open design questions flagged in themedium-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
onrequestendbanner 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.fix(debugbar): hide collapsed extras without matching notice specs