From 4d26e12d058281c83be77b603c4783d290227ddf Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Tue, 14 Jul 2026 09:22:58 -0700 Subject: [PATCH] feat(events): dev-mode debug-bar notice when ?reload=true is refused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since the fail-closed reload gate (4.0.4, #3062), a refused ?reload=true is a silent no-op in the browser, which keeps producing 'reload is broken' support reports from users whose 3.x muscle memory is an empty reloadPassword. The reload gate in all four public/Application.cfc template copies now records why a requested reload did not fire in request.wheels.reloadRefusedReason (emptyPassword, missingPasswordParam, or the deliberately generic refused for wrong-password/rate-limited — no oracle on top of $secureCompare), and the debug bar renders a development-only banner with the matching fix inline. Message text and the development gate live framework-side so wording can change without template drift. Other environments are unchanged (silent no-op plus wheels_security.log). Pinned by a runtime render spec (DebugBarReloadRefusedNoticeSpec) and a structural 4-copy parity spec (ReloadRefusedNoticeParitySpec). Also documents the fail-closed contract in the 3x-to-4x troubleshooting guide. Closes #3311 Signed-off-by: Peter Amiri --- .../reload-refused-dev-notice.added.md | 1 + .../templates/app/public/Application.cfc | 19 +++ examples/starter-app/public/Application.cfc | 19 +++ examples/tweet/public/Application.cfc | 19 +++ public/Application.cfc | 19 +++ vendor/wheels/events/onrequestend/debug.cfm | 22 ++++ .../cli/ReloadRefusedNoticeParitySpec.cfc | 105 ++++++++++++++++ .../DebugBarReloadRefusedNoticeSpec.cfc | 116 ++++++++++++++++++ .../docs/v4-0-0/upgrading/3x-to-4x.mdx | 1 + 9 files changed, 321 insertions(+) create mode 100644 changelog.d/reload-refused-dev-notice.added.md create mode 100644 vendor/wheels/tests/specs/cli/ReloadRefusedNoticeParitySpec.cfc create mode 100644 vendor/wheels/tests/specs/events/DebugBarReloadRefusedNoticeSpec.cfc diff --git a/changelog.d/reload-refused-dev-notice.added.md b/changelog.d/reload-refused-dev-notice.added.md new file mode 100644 index 0000000000..83b26a3b12 --- /dev/null +++ b/changelog.d/reload-refused-dev-notice.added.md @@ -0,0 +1 @@ +- In the development environment, a refused `?reload=true` is no longer a silent no-op: the debug bar now renders an inline notice explaining why the reload did not fire and what to do. Three cases are distinguished — `reloadPassword` is empty (URL reload disabled, fail-closed since 4.0.4, with the `set(reloadPassword=env('WHEELS_RELOAD_PASSWORD', ''))` fix inline), the `password` URL parameter is missing, and a deliberately generic "refused" for wrong-password or rate-limited attempts (pointing at `wheels_security.log` without distinguishing the two, so the notice adds no oracle on top of the constant-time compare). The reload gate in all four `public/Application.cfc` template copies records the refusal reason in `request.wheels.reloadRefusedReason` (pinned by a structural parity spec); message text and the development-only gate live framework-side in the debug bar. Other environments are unchanged: silent no-op plus `wheels_security.log`, exactly as before ([#3311](https://github.com/wheels-dev/wheels/issues/3311)) diff --git a/cli/lucli/templates/app/public/Application.cfc b/cli/lucli/templates/app/public/Application.cfc index 21f1dbb142..93a37230f8 100644 --- a/cli/lucli/templates/app/public/Application.cfc +++ b/cli/lucli/templates/app/public/Application.cfc @@ -330,6 +330,25 @@ component output="false" { // Fail silently if logging fails } } + // Record WHY a requested reload did not fire so the framework's debug + // bar can render a development-only notice instead of a silent no-op + // (issue #3311). Recording is environment-agnostic — a request-scope + // flag, no output; the message text and the development-environment + // gate live framework-side in vendor/wheels/events/onrequestend/debug.cfm + // so wording can improve without template drift. Wrong-password and + // rate-limited attempts deliberately collapse into one generic reason + // so the notice adds no oracle on top of $secureCompare(). + if (!local.reloadAuthorized && StructKeyExists(request, "wheels")) { + local.reloadPasswordConfigured = StructKeyExists(application.wheels, "reloadPassword") + && Len(application.wheels.reloadPassword); + if (!local.reloadPasswordConfigured) { + request.wheels.reloadRefusedReason = "emptyPassword"; + } else if (!StructKeyExists(url, "password")) { + request.wheels.reloadRefusedReason = "missingPasswordParam"; + } else { + request.wheels.reloadRefusedReason = "refused"; + } + } } if (local.reloadAuthorized) { application.wo.$debugPoint("total,reload"); diff --git a/examples/starter-app/public/Application.cfc b/examples/starter-app/public/Application.cfc index 76bc8c6639..b945e0deb0 100644 --- a/examples/starter-app/public/Application.cfc +++ b/examples/starter-app/public/Application.cfc @@ -303,6 +303,25 @@ component output="false" { // Fail silently if logging fails } } + // Record WHY a requested reload did not fire so the framework's debug + // bar can render a development-only notice instead of a silent no-op + // (issue #3311). Recording is environment-agnostic — a request-scope + // flag, no output; the message text and the development-environment + // gate live framework-side in vendor/wheels/events/onrequestend/debug.cfm + // so wording can improve without template drift. Wrong-password and + // rate-limited attempts deliberately collapse into one generic reason + // so the notice adds no oracle on top of $secureCompare(). + if (!local.reloadAuthorized && StructKeyExists(request, "wheels")) { + local.reloadPasswordConfigured = StructKeyExists(application.wheels, "reloadPassword") + && Len(application.wheels.reloadPassword); + if (!local.reloadPasswordConfigured) { + request.wheels.reloadRefusedReason = "emptyPassword"; + } else if (!StructKeyExists(url, "password")) { + request.wheels.reloadRefusedReason = "missingPasswordParam"; + } else { + request.wheels.reloadRefusedReason = "refused"; + } + } } if (local.reloadAuthorized) { application.wo.$debugPoint("total,reload"); diff --git a/examples/tweet/public/Application.cfc b/examples/tweet/public/Application.cfc index 76bc8c6639..b945e0deb0 100755 --- a/examples/tweet/public/Application.cfc +++ b/examples/tweet/public/Application.cfc @@ -303,6 +303,25 @@ component output="false" { // Fail silently if logging fails } } + // Record WHY a requested reload did not fire so the framework's debug + // bar can render a development-only notice instead of a silent no-op + // (issue #3311). Recording is environment-agnostic — a request-scope + // flag, no output; the message text and the development-environment + // gate live framework-side in vendor/wheels/events/onrequestend/debug.cfm + // so wording can improve without template drift. Wrong-password and + // rate-limited attempts deliberately collapse into one generic reason + // so the notice adds no oracle on top of $secureCompare(). + if (!local.reloadAuthorized && StructKeyExists(request, "wheels")) { + local.reloadPasswordConfigured = StructKeyExists(application.wheels, "reloadPassword") + && Len(application.wheels.reloadPassword); + if (!local.reloadPasswordConfigured) { + request.wheels.reloadRefusedReason = "emptyPassword"; + } else if (!StructKeyExists(url, "password")) { + request.wheels.reloadRefusedReason = "missingPasswordParam"; + } else { + request.wheels.reloadRefusedReason = "refused"; + } + } } if (local.reloadAuthorized) { application.wo.$debugPoint("total,reload"); diff --git a/public/Application.cfc b/public/Application.cfc index af1b2c610f..9fd51547d5 100644 --- a/public/Application.cfc +++ b/public/Application.cfc @@ -331,6 +331,25 @@ component output="false" { // Fail silently if logging fails } } + // Record WHY a requested reload did not fire so the framework's debug + // bar can render a development-only notice instead of a silent no-op + // (issue #3311). Recording is environment-agnostic — a request-scope + // flag, no output; the message text and the development-environment + // gate live framework-side in vendor/wheels/events/onrequestend/debug.cfm + // so wording can improve without template drift. Wrong-password and + // rate-limited attempts deliberately collapse into one generic reason + // so the notice adds no oracle on top of $secureCompare(). + if (!local.reloadAuthorized && StructKeyExists(request, "wheels")) { + local.reloadPasswordConfigured = StructKeyExists(application.wheels, "reloadPassword") + && Len(application.wheels.reloadPassword); + if (!local.reloadPasswordConfigured) { + request.wheels.reloadRefusedReason = "emptyPassword"; + } else if (!StructKeyExists(url, "password")) { + request.wheels.reloadRefusedReason = "missingPasswordParam"; + } else { + request.wheels.reloadRefusedReason = "refused"; + } + } } if (local.reloadAuthorized) { application.wo.$debugPoint("total,reload"); diff --git a/vendor/wheels/events/onrequestend/debug.cfm b/vendor/wheels/events/onrequestend/debug.cfm index d08fab33e6..ad7b2c944f 100644 --- a/vendor/wheels/events/onrequestend/debug.cfm +++ b/vendor/wheels/events/onrequestend/debug.cfm @@ -87,6 +87,28 @@ OR (StructKeyExists(url, "format") AND ListFindNoCase("json,xml,csv,pdf", url.fo
+ + +
+ Reload not performed. + + URL-based reload is disabled because reloadPassword is empty (fail-closed since 4.0.4). + Set set(reloadPassword=env('WHEELS_RELOAD_PASSWORD', '')) in config/settings.cfm, + put the value in .env, then reload with ?reload=true&password=... + + A reloadPassword is configured but the request carried no password parameter. + Append &password=<your reloadPassword> to the URL. + + The reload request was refused. Check wheels_security.log for details. + +
+
+
diff --git a/vendor/wheels/tests/specs/cli/ReloadRefusedNoticeParitySpec.cfc b/vendor/wheels/tests/specs/cli/ReloadRefusedNoticeParitySpec.cfc new file mode 100644 index 0000000000..c405db4ed8 --- /dev/null +++ b/vendor/wheels/tests/specs/cli/ReloadRefusedNoticeParitySpec.cfc @@ -0,0 +1,105 @@ +/** + * Issue ##3311 — dev-mode inline notice when ?reload=true is refused. + * + * The reload gate in the app template's onRequestStart() (fail-closed since + * ##3062) must RECORD why a requested reload did not fire, so the framework's + * debug bar (vendor/wheels/events/onrequestend/debug.cfm) can surface a + * development-only notice instead of a silent no-op. Recording lives in the + * template copies; message text and the development-environment gate live + * framework-side so wording can improve without template drift. + * + * Contract, which ALL FOUR same-lineage copies of public/Application.cfc must + * carry (same lineage as ReloadPasswordGateParitySpec.cfc): + * + * 1. A refused reload records request.wheels.reloadRefusedReason with one of + * exactly three reasons: "emptyPassword" (no non-empty reloadPassword is + * configured), "missingPasswordParam" (password configured but no + * password parameter supplied), "refused" (everything else). + * 2. NO ORACLE: wrong-password and rate-limited refusals must collapse into + * the single generic "refused" reason — the copies must not record a + * reason string that distinguishes them. + * + * Structural spec (no runtime): exercising the gate at runtime would + * applicationStop() the suite mid-run. Modeled on + * ReloadPasswordGateParitySpec.cfc. + */ +component extends="wheels.WheelsTest" { + + function run() { + + describe("reload-refused notice recording parity (issue ##3311)", () => { + + // expandPath("/wheels") resolves to vendor/wheels via the + // configured Lucee mapping; the repo root is two levels above. + var repoRoot = expandPath("/wheels/../.."); + var targets = [ + "cli/lucli/templates/app/public/Application.cfc", + "public/Application.cfc", + "examples/tweet/public/Application.cfc", + "examples/starter-app/public/Application.cfc" + ]; + + for (var rel in targets) { + // Capture the loop variable so the closure body binds the + // current value, not the final iteration's value. + (function(relPath) { + + it("records all three refusal reasons in " & relPath, () => { + var absolute = repoRoot & "/" & relPath; + expect(fileExists(absolute)).toBeTrue("Missing file: " & absolute); + var content = fileRead(absolute); + + expect( + content contains 'request.wheels.reloadRefusedReason = "emptyPassword"' + ).toBeTrue( + relPath & " must record reloadRefusedReason=emptyPassword when a " + & "reload is requested with no non-empty reloadPassword configured " + & "(issue ##3311)." + ); + expect( + content contains 'request.wheels.reloadRefusedReason = "missingPasswordParam"' + ).toBeTrue( + relPath & " must record reloadRefusedReason=missingPasswordParam when " + & "a password is configured but the request carried no password " + & "parameter (issue ##3311)." + ); + expect( + content contains 'request.wheels.reloadRefusedReason = "refused"' + ).toBeTrue( + relPath & " must record the generic reloadRefusedReason=refused for " + & "wrong-password/rate-limited attempts (issue ##3311)." + ); + }); + + it("keeps wrong-password and rate-limited refusals indistinguishable in " & relPath, () => { + var absolute = repoRoot & "/" & relPath; + expect(fileExists(absolute)).toBeTrue("Missing file: " & absolute); + var content = fileRead(absolute); + + // The only assignments to the flag are the three contract reasons — + // no copy may grow a reason that leaks WHY the compare failed. + var assignments = REMatch("request\.wheels\.reloadRefusedReason\s*=\s*""[^""]*""", content); + expect(ArrayLen(assignments) == 3).toBeTrue( + relPath & " must assign reloadRefusedReason exactly three times " + & "(emptyPassword, missingPasswordParam, refused) — found " + & ArrayLen(assignments) & " (issue ##3311)." + ); + for (var assignment in assignments) { + expect( + REFindNoCase("wrong|incorrect|rate", assignment) == 0 + ).toBeTrue( + relPath & " records a refusal reason that distinguishes " + & "wrong-password from rate-limited — the notice must stay " + & "oracle-free (issue ##3311): " & assignment + ); + } + }); + + })(rel); + } + + }); + + } + +} diff --git a/vendor/wheels/tests/specs/events/DebugBarReloadRefusedNoticeSpec.cfc b/vendor/wheels/tests/specs/events/DebugBarReloadRefusedNoticeSpec.cfc new file mode 100644 index 0000000000..9f36a33a9a --- /dev/null +++ b/vendor/wheels/tests/specs/events/DebugBarReloadRefusedNoticeSpec.cfc @@ -0,0 +1,116 @@ +/** + * Issue ##3311 — dev-mode inline notice when ?reload=true is refused. + * + * Since the fail-closed reload gate (##3062, 4.0.4), a refused ?reload=true is + * a silent no-op in the browser: the only signals are wheels_security.log and + * the boot warning, which keeps producing "reload is broken" support reports. + * + * The app template's reload gate now records WHY a requested reload did not + * fire in request.wheels.reloadRefusedReason ("emptyPassword", + * "missingPasswordParam", or the deliberately generic "refused" for + * wrong-password/rate-limited), and the debug bar renders a banner for it — + * in the development environment only. The "refused" reason must stay a + * single generic message so the notice adds no oracle distinguishing a wrong + * password from a rate-limited source. + */ +component extends="wheels.WheelsTest" { + + function run() { + describe("debug.cfm reload-refused notice (issue 3311)", () => { + + it("renders the empty-password notice in development", () => { + var output = $renderDebugBar(environment = "development", reason = "emptyPassword"); + expect(output contains 'data-wdb-reload-refused="emptyPassword"').toBeTrue( + "the banner must render and carry the emptyPassword reason" + ); + expect(output contains "reloadPassword is empty").toBeTrue( + "the banner must name the setting that disables URL reload" + ); + expect(output contains "config/settings.cfm").toBeTrue( + "the banner must say where to set the reload password" + ); + }); + + it("renders the missing-password-parameter notice in development", () => { + var output = $renderDebugBar(environment = "development", reason = "missingPasswordParam"); + expect(output contains 'data-wdb-reload-refused="missingPasswordParam"').toBeTrue( + "the banner must render and carry the missingPasswordParam reason" + ); + expect(output contains "password parameter").toBeTrue( + "the banner must say the password URL parameter is required" + ); + }); + + it("renders one generic notice for wrong-password/rate-limited refusals", () => { + var output = $renderDebugBar(environment = "development", reason = "refused"); + expect(output contains 'data-wdb-reload-refused="refused"').toBeTrue( + "the banner must render and carry the generic refused reason" + ); + expect(output contains "wheels_security.log").toBeTrue( + "the generic notice must point at wheels_security.log" + ); + // No oracle: the rendered notice must not say whether the password was + // wrong or the source was rate-limited. + expect(REFindNoCase("wrong|incorrect|rate.?limit", output) == 0).toBeTrue( + "the generic notice must not distinguish wrong-password from rate-limited" + ); + }); + + it("renders no notice outside development even when a reason was recorded", () => { + var output = $renderDebugBar(environment = "testing", reason = "emptyPassword"); + expect(output contains "data-wdb-reload-refused").toBeFalse( + "the notice is a development-only surface (issue 3311 acceptance criteria)" + ); + }); + + it("renders no notice when no refusal reason was recorded", () => { + var output = $renderDebugBar(environment = "development", reason = ""); + expect(output contains "data-wdb-reload-refused").toBeFalse( + "no banner without a recorded refusal" + ); + }); + + }); + } + + /** + * Renders the debug bar template with the given environment and (optional) + * request.wheels.reloadRefusedReason applied, restoring all touched state. + * Modeled on DebugBarEnvQuickSwitchSpec.cfc. + */ + private string function $renderDebugBar(required string environment, required string reason) { + var priorEnvironment = application.wheels.environment; + var priorReqWheels = StructKeyExists(request, "wheels") ? Duplicate(request.wheels) : {}; + // debug.cfm bails out (cfexit) when url.format is one of json/xml/csv/pdf + // so it never breaks an API response. The test runner is hit with + // format=json — clear it for the duration of the include. + var hadUrlFormat = StructKeyExists(url, "format"); + var priorUrlFormat = hadUrlFormat ? url.format : ""; + var output = ""; + try { + application.wheels.environment = arguments.environment; + if (!StructKeyExists(request, "wheels")) { + request.wheels = {}; + } + request.wheels.execution = {total = 0}; + request.wheels.params = {controller = "wheels", action = "tests", route = ""}; + if (Len(arguments.reason)) { + request.wheels.reloadRefusedReason = arguments.reason; + } else { + StructDelete(request.wheels, "reloadRefusedReason"); + } + if (hadUrlFormat) { + StructDelete(url, "format"); + } + output = application.wo.$includeAndReturnOutput($template = "/wheels/events/onrequestend/debug.cfm"); + } finally { + application.wheels.environment = priorEnvironment; + request.wheels = priorReqWheels; + if (hadUrlFormat) { + url.format = priorUrlFormat; + } + } + return output; + } + +} diff --git a/web/sites/guides/src/content/docs/v4-0-0/upgrading/3x-to-4x.mdx b/web/sites/guides/src/content/docs/v4-0-0/upgrading/3x-to-4x.mdx index 913a5d6317..46290567fc 100644 --- a/web/sites/guides/src/content/docs/v4-0-0/upgrading/3x-to-4x.mdx +++ b/web/sites/guides/src/content/docs/v4-0-0/upgrading/3x-to-4x.mdx @@ -503,6 +503,7 @@ These are additive in 4.0 and worth adopting during the upgrade window: - **`Wheels.ViteAssetNotFound` thrown on first request after deploy.** The deploy shipped code referencing a Vite entrypoint that isn't in the manifest. Rebuild Vite assets (`npm run build`) before deploy, or temporarily `set(viteStrictManifest=false)` to restore 3.x silent fallback. See #2133. - **Boot fails with a "wirebox" package-not-found error.** The `wirebox` package was removed from `vendor/wheels/` (#1883). Installing the legacy adapter does not restore it. Replace `new wirebox.system.ioc.Injector(...)` in `Application.cfc` with `new wheels.Injector()`, and replace any `application.wirebox` references with `application.wheelsdi`. See [item 10](#10-applicationwirebox-renamed-to-applicationwheelsdi). - **"reloadPassword is empty" boot warning despite having `reloadPassword` in `.env`.** The reload password is populated only by `set(reloadPassword = ...)` in `config/settings.cfm` — a `.env` value does not flow through automatically. Add `set(reloadPassword = env("WHEELS_RELOAD_PASSWORD", ""));` to `config/settings.cfm`. See #2631. +- **`?reload=true` does nothing — the app never restarts.** URL-based reload fails closed since 4.0.4: it requires a non-empty configured `reloadPassword` AND a matching `password` URL parameter (`?reload=true&password=...`). An empty `reloadPassword` — including the empty-string fallback from a missing `.env` key — disables URL reload entirely; in 3.x an empty password meant "no password required", so this breaks old muscle memory silently. In the development environment the debug bar now shows an inline notice explaining why a requested reload was refused; other environments log to `wheels_security.log` only. Wrong-password attempts are rate-limited (5 failures per IP within 5 minutes locks the source out for the window). See #3062, #3311. - **Production servers report `environment=development` despite `.env` containing `environment=production`.** `application.env.environment` is not reliably populated before `config/environment.cfm` runs — the load-order gap means the expression resolves to `""` and `set()` picks up the wrong value. Replace `set(environment=application.env.environment)` with `set(environment=env("environment", "production"))`. The `"production"` default ensures a missing key fails safe to production rather than development. See #2709. - **Static assets under `/miscellaneous/`, `/javascripts/`, `/stylesheets/`, or `/files/` return 404 after upgrade.** LuCLI's bundled-default `rewrite.config` uses a narrow allow-list (`images|css|js|fonts|assets|static`) that excludes 3.x-conventional directory names. `wheels start` now auto-provisions a project-level `rewrite.config` that covers these dirs on the first boot. If you upgraded before this fix, run `wheels start` once against your upgraded project — the file is dropped automatically and existing user-authored `rewrite.config` files are left untouched. See #2626.