Skip to content

docs: clarify reload password CLI/HTTP threat model (#2477)#2516

Merged
bpamiri merged 1 commit intodevelopfrom
claude/issue-2477-docs
May 8, 2026
Merged

docs: clarify reload password CLI/HTTP threat model (#2477)#2516
bpamiri merged 1 commit intodevelopfrom
claude/issue-2477-docs

Conversation

@bpamiri
Copy link
Copy Markdown
Collaborator

@bpamiri bpamiri commented May 8, 2026

Summary

Documents the design of reloadPassword so the question raised in #2477 doesn't recur. No code changes — wheels reload behaviour is unchanged.

Background

#2477 asked why wheels reload doesn't re-prompt for the configured reload password. Researched the convention across Rails, Laravel, Symfony, Django, and Phoenix:

  • Rails has no reload-password concept at all; restart goes through deploy tooling with SSH-key auth.
  • Laravel php artisan optimize:clear / cache:clear / up / down never authenticate. php artisan down --secret= goes the opposite direction — the CLI mints a token; HTTP visitors must present it.
  • Symfony / Django / Phoenix — same shape. CLI carries owner-of-the-project trust by definition.

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() hint

Expanded the LuCLI hint comment with a short threat-model note and a pointer at the security guide. So wheels --help and wheels reload --help carry the explanation.

command-line-tools/wheels-commands/dev-server.mdx

Aside type="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.

deployment/security-hardening.mdx

The existing ## Reload password section grew two new subsections:

  • "What it protects (and what it doesn't)" — names the threat model explicitly (remote attackers hitting ?reload=true), lists what the password does NOT gate (the CLI, full restart, filesystem access).
  • "Hardening beyond the password" — concrete defence-in-depth recommendations: chmod 600 .env, secret managers, network ACLs.

Linked back to #2477 for the rationale trail.

Test plan

  • Render dev-server.mdx — the new Aside appears between the synopsis and the example, with a working link to security-hardening/#reload-password.
  • Render security-hardening.mdx — two new subsections appear under ## Reload password.
  • Run wheels reload --help (after rebuild) — hint shows the new threat-model note.
  • Confirm no behavioural change: wheels reload still works exactly as before; only docs/hints differ.

Generated by Claude Code

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.
@bpamiri bpamiri merged commit 57b71a5 into develop May 8, 2026
10 of 11 checks passed
@bpamiri bpamiri deleted the claude/issue-2477-docs branch May 8, 2026 23:18

### 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.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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=true probes
  • 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

  1. Set reloadPassword = "correcthorse" in config/settings.cfm.
  2. Start the dev server.
  3. From a remote IP, request GET /?reload=true&password=wrong.
  4. Trace through public/Application.cfc: the if at line 204 evaluates the password equality — wrong != correcthorse — so the StructKeyExists(url, "password") && url.password == application.wheels.reloadPassword clause is false, the whole (!StructKeyExists … || !Len … || …) disjunction is false, and the if-body is skipped.
  5. Execution continues past the if to line 222-232. $runOnRequestStart returns true.
  6. The framework renders the requested URL (/) normally. The HTTP response is whatever the homepage normally returns — typically 200 OK, not 403.
  7. Separately, onapplicationstart.cfc logs Reload password rejected from <ip> to wheels_security.log and 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.log and rate-limited per IP.

This is a one-sentence doc fix in the file already touched by this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants