docs: clarify reload password CLI/HTTP threat model (#2477)#2516
docs: clarify reload password CLI/HTTP threat model (#2477)#2516
Conversation
Issue #2477 asked why `wheels reload` doesn't re-prompt for the configured `reloadPassword`. Researched the Rails / Laravel / Symfony / Django / Phoenix conventions and confirmed Wheels' current behaviour matches them all: the password gates the HTTP endpoint, the CLI is treated as already-trusted because it has filesystem access. Closing the issue as working-as-designed; this PR makes the design intent visible in three places so the question doesn't recur. Three doc surfaces updated: 1. `cli/lucli/Module.cfc::reload()` — hint expanded with a short note on the threat model and a pointer at the security guide. So `wheels --help` and `wheels reload --help` carry the explanation. 2. `command-line-tools/wheels-commands/dev-server.mdx` — `Aside` note on the `wheels reload` reference page explaining why the CLI doesn't re-prompt, with the Rails/Laravel/etc. parity point and a deep-link to the security-hardening section. 3. `deployment/security-hardening.mdx` — `Reload password` section grew two new subsections: "What it protects (and what it doesn't)" naming the threat model explicitly, listing what the password does NOT gate (the CLI, full restart, filesystem access), and "Hardening beyond the password" with concrete defence-in-depth recommendations (chmod, secret managers, network ACLs). Linked back to #2477 for the rationale trail. No code changes. Behaviour is unchanged.
|
|
||
| ### What it protects (and what it doesn't) | ||
|
|
||
| The reload password defends a single threat: **a remote attacker who can reach your app over HTTP** otherwise being able to call `/?reload=true` and clobber app state, or `/?reload=production` and switch environments at will. With the password set, those URLs return 403 unless the attacker also knows the secret. |
There was a problem hiding this comment.
🔴 The new "What it protects (and what it doesn't)" subsection states that with the password set, the reload URLs "return 403 unless the attacker also knows the secret," but the framework does not return 403 on this path. In public/Application.cfc (lines 204-220) a wrong/missing password causes the reload if-block to be skipped and the request falls through to normal processing (typically a 200 OK with the homepage); the env-switch path in vendor/wheels/events/onapplicationstart.cfc similarly just sets reloadPasswordMatched=false, logs to wheels_security.log, rate-limits by IP, and proceeds. Suggest rewording to e.g. "those URLs are silently ignored unless the attacker also knows the secret; failed attempts are logged to wheels_security.log and rate-limited per IP" — matching the prior paragraph (line 155) which already describes the real detection signal.
Extended reasoning...
What the bug is
The added subsection on line 168 of web/sites/guides/src/content/docs/v4-0-0-snapshot/deployment/security-hardening.mdx says:
With the password set, those URLs return 403 unless the attacker also knows the secret.
This is factually wrong. There is no HTTP 403 response anywhere on the reload-password rejection path.
The actual code path
HTTP ?reload=true rejection (public/Application.cfc lines 204-220):
if (
StructKeyExists(url, "reload")
&& (
!StructKeyExists(application, "wheels") || !StructKeyExists(application.wheels, "reloadPassword")
|| !Len(application.wheels.reloadPassword)
|| (StructKeyExists(url, "password") && url.password == application.wheels.reloadPassword)
)
) {
// ...restart and redirect...
return false;
}When the password does not match, the entire if-block is skipped — there is no else branch, no cfheader statusCode=403, no abort, no throw. Execution falls through to the normal handler (lines 222-232) which returns true, meaning the framework continues to render the requested URL with whatever status it would normally produce — typically 200 OK with the homepage.
Environment-switch ?reload=production rejection (vendor/wheels/events/onapplicationstart.cfc lines 150-194): a wrong password sets local.reloadPasswordMatched = false, the include of /config/environment.cfm runs anyway with the configured environment, the failure is logged to wheels_security.log, and the rate-limit counter increments. Again, no HTTP status is set.
A grep across the framework for 403 / statusCode / Forbidden confirms zero matches on the reload path. The 403s that exist live in unrelated places (vendor/wheels/auth/ middleware, renderingSpec.cfc tests, MCP views, the rendering status-code map).
Why this matters
This is a security-hardening guide — the audience is operators configuring defence-in-depth tooling. They will reasonably build:
- WAF/IDS signatures expecting 403s on
?reload=trueprobes - Alerting that triggers on bursts of 403s as a probe-detection signal
- Threat-modelling assumptions that the reload path is actively rejected at the HTTP layer
All of those would silently fail to fire. The actual detection signal is wheels_security.log entries plus the per-IP rate limiter — which the prior paragraph in the same doc (line 155) already correctly describes. The contradiction between line 155 and line 168 is what makes this especially confusing for readers.
Step-by-step proof
- Set
reloadPassword = "correcthorse"inconfig/settings.cfm. - Start the dev server.
- From a remote IP, request
GET /?reload=true&password=wrong. - Trace through
public/Application.cfc: theifat line 204 evaluates the password equality —wrong != correcthorse— so theStructKeyExists(url, "password") && url.password == application.wheels.reloadPasswordclause is false, the whole(!StructKeyExists … || !Len … || …)disjunction is false, and theif-body is skipped. - Execution continues past the
ifto line 222-232.$runOnRequestStartreturnstrue. - The framework renders the requested URL (
/) normally. The HTTP response is whatever the homepage normally returns — typically 200 OK, not 403. - Separately,
onapplicationstart.cfclogsReload password rejected from <ip>towheels_security.logand the rate limiter increments. Those are the real detection signals.
How to fix
Replace line 168's clause with wording that matches reality. Suggested rewording, mirroring line 155's correct description:
With the password set, those URLs are silently ignored unless the attacker also knows the secret; failed attempts are logged to
wheels_security.logand rate-limited per IP.
This is a one-sentence doc fix in the file already touched by this PR.
Summary
Documents the design of
reloadPasswordso the question raised in #2477 doesn't recur. No code changes —wheels reloadbehaviour is unchanged.Background
#2477 asked why
wheels reloaddoesn't re-prompt for the configured reload password. Researched the convention across Rails, Laravel, Symfony, Django, and Phoenix:php artisan optimize:clear/cache:clear/up/downnever authenticate.php artisan down --secret=goes the opposite direction — the CLI mints a token; HTTP visitors must present it.Wheels' current behaviour is correct and matches them all. Issue closed as working-as-designed; this PR makes the design intent visible in three places.
Changes
cli/lucli/Module.cfc::reload()hintExpanded the LuCLI hint comment with a short threat-model note and a pointer at the security guide. So
wheels --helpandwheels reload --helpcarry the explanation.command-line-tools/wheels-commands/dev-server.mdxAside type="note"on thewheels reloadreference page explaining why the CLI doesn't re-prompt, with the Rails / Laravel / etc. parity point and a deep-link to the security-hardening section.deployment/security-hardening.mdxThe existing
## Reload passwordsection grew two new subsections:?reload=true), lists what the password does NOT gate (the CLI, full restart, filesystem access).chmod 600 .env, secret managers, network ACLs.Linked back to #2477 for the rationale trail.
Test plan
dev-server.mdx— the new Aside appears between the synopsis and the example, with a working link tosecurity-hardening/#reload-password.security-hardening.mdx— two new subsections appear under## Reload password.wheels reload --help(after rebuild) — hint shows the new threat-model note.wheels reloadstill works exactly as before; only docs/hints differ.Generated by Claude Code