Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/reload-refused-dev-notice.added.md
Original file line number Diff line number Diff line change
@@ -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))
19 changes: 19 additions & 0 deletions cli/lucli/templates/app/public/Application.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
19 changes: 19 additions & 0 deletions examples/starter-app/public/Application.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
19 changes: 19 additions & 0 deletions examples/tweet/public/Application.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
19 changes: 19 additions & 0 deletions public/Application.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
22 changes: 22 additions & 0 deletions vendor/wheels/events/onrequestend/debug.cfm
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,28 @@ OR (StructKeyExists(url, "format") AND ListFindNoCase("json,xml,csv,pdf", url.fo
<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;">
<style><cfinclude template="/wheels/public/assets/css/debugbar.css"></style>

<!--- ============ RELOAD-REFUSED NOTICE (issue 3311) ============
The app template's reload gate (public/Application.cfc) records
request.wheels.reloadRefusedReason when ?reload= was requested but did not
fire. Development-only surface: other environments stay silent (log-only),
and the generic "refused" reason must never distinguish wrong-password from
rate-limited (no oracle on top of $secureCompare). --->
<cfif StructKeyExists(request.wheels, "reloadRefusedReason") AND $get("environment") IS "development">
<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;">
<strong>Reload not performed.</strong>
<cfif request.wheels.reloadRefusedReason IS "emptyPassword">
URL-based reload is disabled because <code>reloadPassword</code> is empty (fail-closed since 4.0.4).
Set <code>set(reloadPassword=env('WHEELS_RELOAD_PASSWORD', ''))</code> in <code>config/settings.cfm</code>,
put the value in <code>.env</code>, then reload with <code>?reload=true&amp;password=...</code>
<cfelseif request.wheels.reloadRefusedReason IS "missingPasswordParam">
A <code>reloadPassword</code> is configured but the request carried no password parameter.
Append <code>&amp;password=&lt;your reloadPassword&gt;</code> to the URL.
<cfelse>
The reload request was refused. Check <code>wheels_security.log</code> for details.
</cfif>
</div>
</cfif>

<!--- ============ COLLAPSED BAR ============ --->
<div class="wdb-bar" id="wdb-bar">
<!--- Wheels logo / toggle --->
Expand Down
105 changes: 105 additions & 0 deletions vendor/wheels/tests/specs/cli/ReloadRefusedNoticeParitySpec.cfc
Original file line number Diff line number Diff line change
@@ -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);
}

});

}

}
116 changes: 116 additions & 0 deletions vendor/wheels/tests/specs/events/DebugBarReloadRefusedNoticeSpec.cfc
Original file line number Diff line number Diff line change
@@ -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 "<code>reloadPassword</code> 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;
}

}
Loading
Loading