Skip to content

Commit aa7f01b

Browse files
bpamiriPeter Amiri
andauthored
feat(events): dev-mode debug-bar notice when ?reload=true is refused (#3313)
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 <petera@pai.com> Co-authored-by: Peter Amiri <petera@pai.com>
1 parent 35a1956 commit aa7f01b

9 files changed

Lines changed: 321 additions & 0 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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))

cli/lucli/templates/app/public/Application.cfc

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,25 @@ component output="false" {
330330
// Fail silently if logging fails
331331
}
332332
}
333+
// Record WHY a requested reload did not fire so the framework's debug
334+
// bar can render a development-only notice instead of a silent no-op
335+
// (issue #3311). Recording is environment-agnostic — a request-scope
336+
// flag, no output; the message text and the development-environment
337+
// gate live framework-side in vendor/wheels/events/onrequestend/debug.cfm
338+
// so wording can improve without template drift. Wrong-password and
339+
// rate-limited attempts deliberately collapse into one generic reason
340+
// so the notice adds no oracle on top of $secureCompare().
341+
if (!local.reloadAuthorized && StructKeyExists(request, "wheels")) {
342+
local.reloadPasswordConfigured = StructKeyExists(application.wheels, "reloadPassword")
343+
&& Len(application.wheels.reloadPassword);
344+
if (!local.reloadPasswordConfigured) {
345+
request.wheels.reloadRefusedReason = "emptyPassword";
346+
} else if (!StructKeyExists(url, "password")) {
347+
request.wheels.reloadRefusedReason = "missingPasswordParam";
348+
} else {
349+
request.wheels.reloadRefusedReason = "refused";
350+
}
351+
}
333352
}
334353
if (local.reloadAuthorized) {
335354
application.wo.$debugPoint("total,reload");

examples/starter-app/public/Application.cfc

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,25 @@ component output="false" {
303303
// Fail silently if logging fails
304304
}
305305
}
306+
// Record WHY a requested reload did not fire so the framework's debug
307+
// bar can render a development-only notice instead of a silent no-op
308+
// (issue #3311). Recording is environment-agnostic — a request-scope
309+
// flag, no output; the message text and the development-environment
310+
// gate live framework-side in vendor/wheels/events/onrequestend/debug.cfm
311+
// so wording can improve without template drift. Wrong-password and
312+
// rate-limited attempts deliberately collapse into one generic reason
313+
// so the notice adds no oracle on top of $secureCompare().
314+
if (!local.reloadAuthorized && StructKeyExists(request, "wheels")) {
315+
local.reloadPasswordConfigured = StructKeyExists(application.wheels, "reloadPassword")
316+
&& Len(application.wheels.reloadPassword);
317+
if (!local.reloadPasswordConfigured) {
318+
request.wheels.reloadRefusedReason = "emptyPassword";
319+
} else if (!StructKeyExists(url, "password")) {
320+
request.wheels.reloadRefusedReason = "missingPasswordParam";
321+
} else {
322+
request.wheels.reloadRefusedReason = "refused";
323+
}
324+
}
306325
}
307326
if (local.reloadAuthorized) {
308327
application.wo.$debugPoint("total,reload");

examples/tweet/public/Application.cfc

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,25 @@ component output="false" {
303303
// Fail silently if logging fails
304304
}
305305
}
306+
// Record WHY a requested reload did not fire so the framework's debug
307+
// bar can render a development-only notice instead of a silent no-op
308+
// (issue #3311). Recording is environment-agnostic — a request-scope
309+
// flag, no output; the message text and the development-environment
310+
// gate live framework-side in vendor/wheels/events/onrequestend/debug.cfm
311+
// so wording can improve without template drift. Wrong-password and
312+
// rate-limited attempts deliberately collapse into one generic reason
313+
// so the notice adds no oracle on top of $secureCompare().
314+
if (!local.reloadAuthorized && StructKeyExists(request, "wheels")) {
315+
local.reloadPasswordConfigured = StructKeyExists(application.wheels, "reloadPassword")
316+
&& Len(application.wheels.reloadPassword);
317+
if (!local.reloadPasswordConfigured) {
318+
request.wheels.reloadRefusedReason = "emptyPassword";
319+
} else if (!StructKeyExists(url, "password")) {
320+
request.wheels.reloadRefusedReason = "missingPasswordParam";
321+
} else {
322+
request.wheels.reloadRefusedReason = "refused";
323+
}
324+
}
306325
}
307326
if (local.reloadAuthorized) {
308327
application.wo.$debugPoint("total,reload");

public/Application.cfc

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,25 @@ component output="false" {
331331
// Fail silently if logging fails
332332
}
333333
}
334+
// Record WHY a requested reload did not fire so the framework's debug
335+
// bar can render a development-only notice instead of a silent no-op
336+
// (issue #3311). Recording is environment-agnostic — a request-scope
337+
// flag, no output; the message text and the development-environment
338+
// gate live framework-side in vendor/wheels/events/onrequestend/debug.cfm
339+
// so wording can improve without template drift. Wrong-password and
340+
// rate-limited attempts deliberately collapse into one generic reason
341+
// so the notice adds no oracle on top of $secureCompare().
342+
if (!local.reloadAuthorized && StructKeyExists(request, "wheels")) {
343+
local.reloadPasswordConfigured = StructKeyExists(application.wheels, "reloadPassword")
344+
&& Len(application.wheels.reloadPassword);
345+
if (!local.reloadPasswordConfigured) {
346+
request.wheels.reloadRefusedReason = "emptyPassword";
347+
} else if (!StructKeyExists(url, "password")) {
348+
request.wheels.reloadRefusedReason = "missingPasswordParam";
349+
} else {
350+
request.wheels.reloadRefusedReason = "refused";
351+
}
352+
}
334353
}
335354
if (local.reloadAuthorized) {
336355
application.wo.$debugPoint("total,reload");

vendor/wheels/events/onrequestend/debug.cfm

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,28 @@ OR (StructKeyExists(url, "format") AND ListFindNoCase("json,xml,csv,pdf", url.fo
8787
<div id="wheels-debugbar" style="all:initial;position:fixed;bottom:0;left:0;right:0;z-index:99999;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,sans-serif;">
8888
<style><cfinclude template="/wheels/public/assets/css/debugbar.css"></style>
8989

90+
<!--- ============ RELOAD-REFUSED NOTICE (issue 3311) ============
91+
The app template's reload gate (public/Application.cfc) records
92+
request.wheels.reloadRefusedReason when ?reload= was requested but did not
93+
fire. Development-only surface: other environments stay silent (log-only),
94+
and the generic "refused" reason must never distinguish wrong-password from
95+
rate-limited (no oracle on top of $secureCompare). --->
96+
<cfif StructKeyExists(request.wheels, "reloadRefusedReason") AND $get("environment") IS "development">
97+
<div data-wdb-reload-refused="#EncodeForHTMLAttribute(request.wheels.reloadRefusedReason)#" style="background:##45475a;color:##f9e2af;padding:8px 14px;font-size:12px;line-height:1.6;border-top:2px solid ##f9e2af;">
98+
<strong>Reload not performed.</strong>
99+
<cfif request.wheels.reloadRefusedReason IS "emptyPassword">
100+
URL-based reload is disabled because <code>reloadPassword</code> is empty (fail-closed since 4.0.4).
101+
Set <code>set(reloadPassword=env('WHEELS_RELOAD_PASSWORD', ''))</code> in <code>config/settings.cfm</code>,
102+
put the value in <code>.env</code>, then reload with <code>?reload=true&amp;password=...</code>
103+
<cfelseif request.wheels.reloadRefusedReason IS "missingPasswordParam">
104+
A <code>reloadPassword</code> is configured but the request carried no password parameter.
105+
Append <code>&amp;password=&lt;your reloadPassword&gt;</code> to the URL.
106+
<cfelse>
107+
The reload request was refused. Check <code>wheels_security.log</code> for details.
108+
</cfif>
109+
</div>
110+
</cfif>
111+
90112
<!--- ============ COLLAPSED BAR ============ --->
91113
<div class="wdb-bar" id="wdb-bar">
92114
<!--- Wheels logo / toggle --->
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* Issue ##3311 — dev-mode inline notice when ?reload=true is refused.
3+
*
4+
* The reload gate in the app template's onRequestStart() (fail-closed since
5+
* ##3062) must RECORD why a requested reload did not fire, so the framework's
6+
* debug bar (vendor/wheels/events/onrequestend/debug.cfm) can surface a
7+
* development-only notice instead of a silent no-op. Recording lives in the
8+
* template copies; message text and the development-environment gate live
9+
* framework-side so wording can improve without template drift.
10+
*
11+
* Contract, which ALL FOUR same-lineage copies of public/Application.cfc must
12+
* carry (same lineage as ReloadPasswordGateParitySpec.cfc):
13+
*
14+
* 1. A refused reload records request.wheels.reloadRefusedReason with one of
15+
* exactly three reasons: "emptyPassword" (no non-empty reloadPassword is
16+
* configured), "missingPasswordParam" (password configured but no
17+
* password parameter supplied), "refused" (everything else).
18+
* 2. NO ORACLE: wrong-password and rate-limited refusals must collapse into
19+
* the single generic "refused" reason — the copies must not record a
20+
* reason string that distinguishes them.
21+
*
22+
* Structural spec (no runtime): exercising the gate at runtime would
23+
* applicationStop() the suite mid-run. Modeled on
24+
* ReloadPasswordGateParitySpec.cfc.
25+
*/
26+
component extends="wheels.WheelsTest" {
27+
28+
function run() {
29+
30+
describe("reload-refused notice recording parity (issue ##3311)", () => {
31+
32+
// expandPath("/wheels") resolves to vendor/wheels via the
33+
// configured Lucee mapping; the repo root is two levels above.
34+
var repoRoot = expandPath("/wheels/../..");
35+
var targets = [
36+
"cli/lucli/templates/app/public/Application.cfc",
37+
"public/Application.cfc",
38+
"examples/tweet/public/Application.cfc",
39+
"examples/starter-app/public/Application.cfc"
40+
];
41+
42+
for (var rel in targets) {
43+
// Capture the loop variable so the closure body binds the
44+
// current value, not the final iteration's value.
45+
(function(relPath) {
46+
47+
it("records all three refusal reasons in " & relPath, () => {
48+
var absolute = repoRoot & "/" & relPath;
49+
expect(fileExists(absolute)).toBeTrue("Missing file: " & absolute);
50+
var content = fileRead(absolute);
51+
52+
expect(
53+
content contains 'request.wheels.reloadRefusedReason = "emptyPassword"'
54+
).toBeTrue(
55+
relPath & " must record reloadRefusedReason=emptyPassword when a "
56+
& "reload is requested with no non-empty reloadPassword configured "
57+
& "(issue ##3311)."
58+
);
59+
expect(
60+
content contains 'request.wheels.reloadRefusedReason = "missingPasswordParam"'
61+
).toBeTrue(
62+
relPath & " must record reloadRefusedReason=missingPasswordParam when "
63+
& "a password is configured but the request carried no password "
64+
& "parameter (issue ##3311)."
65+
);
66+
expect(
67+
content contains 'request.wheels.reloadRefusedReason = "refused"'
68+
).toBeTrue(
69+
relPath & " must record the generic reloadRefusedReason=refused for "
70+
& "wrong-password/rate-limited attempts (issue ##3311)."
71+
);
72+
});
73+
74+
it("keeps wrong-password and rate-limited refusals indistinguishable in " & relPath, () => {
75+
var absolute = repoRoot & "/" & relPath;
76+
expect(fileExists(absolute)).toBeTrue("Missing file: " & absolute);
77+
var content = fileRead(absolute);
78+
79+
// The only assignments to the flag are the three contract reasons —
80+
// no copy may grow a reason that leaks WHY the compare failed.
81+
var assignments = REMatch("request\.wheels\.reloadRefusedReason\s*=\s*""[^""]*""", content);
82+
expect(ArrayLen(assignments) == 3).toBeTrue(
83+
relPath & " must assign reloadRefusedReason exactly three times "
84+
& "(emptyPassword, missingPasswordParam, refused) — found "
85+
& ArrayLen(assignments) & " (issue ##3311)."
86+
);
87+
for (var assignment in assignments) {
88+
expect(
89+
REFindNoCase("wrong|incorrect|rate", assignment) == 0
90+
).toBeTrue(
91+
relPath & " records a refusal reason that distinguishes "
92+
& "wrong-password from rate-limited — the notice must stay "
93+
& "oracle-free (issue ##3311): " & assignment
94+
);
95+
}
96+
});
97+
98+
})(rel);
99+
}
100+
101+
});
102+
103+
}
104+
105+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* Issue ##3311 — dev-mode inline notice when ?reload=true is refused.
3+
*
4+
* Since the fail-closed reload gate (##3062, 4.0.4), a refused ?reload=true is
5+
* a silent no-op in the browser: the only signals are wheels_security.log and
6+
* the boot warning, which keeps producing "reload is broken" support reports.
7+
*
8+
* The app template's reload gate now records WHY a requested reload did not
9+
* fire in request.wheels.reloadRefusedReason ("emptyPassword",
10+
* "missingPasswordParam", or the deliberately generic "refused" for
11+
* wrong-password/rate-limited), and the debug bar renders a banner for it —
12+
* in the development environment only. The "refused" reason must stay a
13+
* single generic message so the notice adds no oracle distinguishing a wrong
14+
* password from a rate-limited source.
15+
*/
16+
component extends="wheels.WheelsTest" {
17+
18+
function run() {
19+
describe("debug.cfm reload-refused notice (issue 3311)", () => {
20+
21+
it("renders the empty-password notice in development", () => {
22+
var output = $renderDebugBar(environment = "development", reason = "emptyPassword");
23+
expect(output contains 'data-wdb-reload-refused="emptyPassword"').toBeTrue(
24+
"the banner must render and carry the emptyPassword reason"
25+
);
26+
expect(output contains "<code>reloadPassword</code> is empty").toBeTrue(
27+
"the banner must name the setting that disables URL reload"
28+
);
29+
expect(output contains "config/settings.cfm").toBeTrue(
30+
"the banner must say where to set the reload password"
31+
);
32+
});
33+
34+
it("renders the missing-password-parameter notice in development", () => {
35+
var output = $renderDebugBar(environment = "development", reason = "missingPasswordParam");
36+
expect(output contains 'data-wdb-reload-refused="missingPasswordParam"').toBeTrue(
37+
"the banner must render and carry the missingPasswordParam reason"
38+
);
39+
expect(output contains "password parameter").toBeTrue(
40+
"the banner must say the password URL parameter is required"
41+
);
42+
});
43+
44+
it("renders one generic notice for wrong-password/rate-limited refusals", () => {
45+
var output = $renderDebugBar(environment = "development", reason = "refused");
46+
expect(output contains 'data-wdb-reload-refused="refused"').toBeTrue(
47+
"the banner must render and carry the generic refused reason"
48+
);
49+
expect(output contains "wheels_security.log").toBeTrue(
50+
"the generic notice must point at wheels_security.log"
51+
);
52+
// No oracle: the rendered notice must not say whether the password was
53+
// wrong or the source was rate-limited.
54+
expect(REFindNoCase("wrong|incorrect|rate.?limit", output) == 0).toBeTrue(
55+
"the generic notice must not distinguish wrong-password from rate-limited"
56+
);
57+
});
58+
59+
it("renders no notice outside development even when a reason was recorded", () => {
60+
var output = $renderDebugBar(environment = "testing", reason = "emptyPassword");
61+
expect(output contains "data-wdb-reload-refused").toBeFalse(
62+
"the notice is a development-only surface (issue 3311 acceptance criteria)"
63+
);
64+
});
65+
66+
it("renders no notice when no refusal reason was recorded", () => {
67+
var output = $renderDebugBar(environment = "development", reason = "");
68+
expect(output contains "data-wdb-reload-refused").toBeFalse(
69+
"no banner without a recorded refusal"
70+
);
71+
});
72+
73+
});
74+
}
75+
76+
/**
77+
* Renders the debug bar template with the given environment and (optional)
78+
* request.wheels.reloadRefusedReason applied, restoring all touched state.
79+
* Modeled on DebugBarEnvQuickSwitchSpec.cfc.
80+
*/
81+
private string function $renderDebugBar(required string environment, required string reason) {
82+
var priorEnvironment = application.wheels.environment;
83+
var priorReqWheels = StructKeyExists(request, "wheels") ? Duplicate(request.wheels) : {};
84+
// debug.cfm bails out (cfexit) when url.format is one of json/xml/csv/pdf
85+
// so it never breaks an API response. The test runner is hit with
86+
// format=json — clear it for the duration of the include.
87+
var hadUrlFormat = StructKeyExists(url, "format");
88+
var priorUrlFormat = hadUrlFormat ? url.format : "";
89+
var output = "";
90+
try {
91+
application.wheels.environment = arguments.environment;
92+
if (!StructKeyExists(request, "wheels")) {
93+
request.wheels = {};
94+
}
95+
request.wheels.execution = {total = 0};
96+
request.wheels.params = {controller = "wheels", action = "tests", route = ""};
97+
if (Len(arguments.reason)) {
98+
request.wheels.reloadRefusedReason = arguments.reason;
99+
} else {
100+
StructDelete(request.wheels, "reloadRefusedReason");
101+
}
102+
if (hadUrlFormat) {
103+
StructDelete(url, "format");
104+
}
105+
output = application.wo.$includeAndReturnOutput($template = "/wheels/events/onrequestend/debug.cfm");
106+
} finally {
107+
application.wheels.environment = priorEnvironment;
108+
request.wheels = priorReqWheels;
109+
if (hadUrlFormat) {
110+
url.format = priorUrlFormat;
111+
}
112+
}
113+
return output;
114+
}
115+
116+
}

0 commit comments

Comments
 (0)