fix(events): defer redirect-after-reload past onApplicationStart so URL env switches persist - #3058
Conversation
…RL env switches persist The redirectAfterReload block in wheels/events/onapplicationstart.cfc called a bare $location() that does not resolve in that mixin-free component, so the post-switch cold start threw 'No matching function [$LOCATION] found', 500'd the request, and the environment switch into production/maintenance silently reverted (those two environments auto-enable redirectAfterReload via events/init/orm.cfm). Latent since the Dec 2024 lifecycle restructure (efb0b1e); became reachable when #3036 made the restart redirect preserve the reload/password parameters. Fixing the call resolution alone is not enough: cflocation aborts the request while onApplicationStart is still running and the engine then discards the half-started application, reverting the switch anyway (verified live on Lucee 7). The block now stashes the stripped URL on the request scope and EventMethods.$runOnRequestStart — same request, after the new application has been persisted — performs the $location() redirect, where it resolves via the Global.cfc inheritance chain. OnAppStartBareHelperGuardSpec pins all three legs: no bare $-helper calls in the mixin-free onapplicationstart.cfc (line-anchored, comment-skipping scan), the producer/consumer deferral pair, and $location resolution on EventMethods. Fixes #3054 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR: This PR fixes the 500-and-silent-revert on URL environment switches (?reload=production|maintenance&password=...) by deferring the redirectAfterReload redirect out of onApplicationStart into EventMethods.$runOnRequestStart, where $location() actually resolves and where aborting no longer discards the half-started application. I verified every load-bearing claim in the PR body against the source and could not refute any of them. Verdict: approve. One optional, non-blocking hardening note below.
Correctness
All clean — each mechanism checked against the code rather than the diff hunk:
- Stash safety:
request.wheelsis guaranteed to exist at the stash site.onapplicationstart.cfc:20callsapplication.wo.$initializeRequestScope()before the redirect block atonapplicationstart.cfc:503, and that helper is guarded withif (!StructKeyExists(request, "wheels"))(vendor/wheels/Global.cfc:2249), so the later call atEventMethods.cfc:327cannot wipe the stash. The "idempotent" claim in the PR body holds. - Receiver resolution:
$runOnRequestStartis invoked withcomponentReference = "wheels.events.EventMethods"(public/Application.cfcand the CLI template), andEventMethodsdeclaresextends="wheels.Global"— where$location()is defined (Global.cfc:326). The third spec pins exactly this. - Lifecycle order: on the post-restart cold start,
onApplicationStartstashes, then the same request'sonRequestStartshort-circuits the restart path via theenvironmentSwitchAlreadyAppliedcheck (env now matchesurl.reload) and reaches$runOnRequestStart, which consumes the stash. The new block correctly guardsStructKeyExists(request, "wheels")for ordinary requests where no app start ran. - Abort semantics:
$location()goes throughcflocationwith a plain-structattributeCollectioncopy (Global.cfc:330-334), so the abort now happens after the application — including the switched environment — has been persisted.onAbortinpublic/Application.cfc:332is benign and behaves identically whether the abort fires in app start or request start. Aborting inside the readOnly$simpleLockreleases the lock on unwind, same as any abort during request start. - No leak: the stash is request-scoped and deleted before the redirect (
EventMethods.cfc:167), so it cannot fire twice or bleed across requests.
Conventions
- Optional, non-blocking: the new line
request.wheels.redirectAfterReloadUrl = local.url;(onapplicationstart.cfc:503) reads the pre-existinglocal.urlvariable — a reserved-scope name per CLAUDE.md anti-pattern #11, and the same name class behind sibling issue #3053. It is explicitlylocal.-scoped on every read/write in this block, so it is safe as-is; renaming tolocal.redirectUrlwhile touching this block would have been cheap hardening. Fine to leave for a follow-up. $location()is already Adobe-2023/2025-compliant (plain-structattributeCollection, invariant #10) — no new tag-wrapper exposure introduced.
Tests
OnAppStartBareHelperGuardSpec.cfc is well-constructed and follows documented prior art:
- Structural scan mirrors
security/BareCfabortGuardSpec.cfc(line-anchored, comment-prefix skipping,ListToArray(content, Chr(10), true)— same idiom atBareCfabortGuardSpec.cfc:63). TheREFindNoCase(..., true)subexpression form has cross-engine prior art invendor/wheels/model/validations.cfc:871. - The scan guards the whole class of bug (any unresolvable bare
$helper(in the mixin-free component), not just$location— good generalization, and the PR body documents RED verification against pre-fix source. - All
##escapes inside spec string literals are correct (the classic suite-killer is avoided). - The runtime flow genuinely cannot execute inside a spec (cold-start
onApplicationStart), so structural pins plus the executable receiver-resolution check is the right coverage shape; the manual RED/GREEN verification on Lucee 7 plus the Adobe 2023 spot-check (with baseline re-verification of the two pre-existing red bundles) fills the gap honestly.
Docs
Changelog fragment present and correctly named (changelog.d/3054-redirectafterreload-deferred-location.fixed.md), with the issue link. No guide changes needed — the fix makes the already-documented security-hardening flow work as written.
Commits
Single commit, valid conventional header (fix(events): ..., 92 chars ≤ 100), DCO sign-off present (Signed-off-by: Peter Amiri). Subject explains the "why" (env switches persist), not just the "what".
…rder and env-switch behavior (#3067) Audit-driven corrections for core-concepts/environments-and-configuration.mdx: - Fix config load order: app.cfm (pseudo-constructor, every request) -> environment.cfm -> framework defaults -> settings.cfm -> config/<env>/settings.cfm -> services.cfm -> routes.cfm - app.cfm executes on every request, not once per app start - Scaffold hard-codes set(environment="development"); WHEELS_ENV is not read by environment.cfm or the wheels CLI; document the two edits (env() read + remove scaffold .env line) required to drive it - Remove set(environment=...) from the settings.cfm example and warn about the half-switched-app failure mode - Replace fictional testing/production "typical settings" rows with the real framework defaults; move log rotation / HTTPS to web server / SecurityHeaders middleware - Add runtime environment switching section: ?reload=<env>&password=..., allowEnvironmentSwitchViaUrl gate semantics (#3036/#3038/#3058) - Cite open issues #3059, #3060, #3062 for current caveats Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ites from behavioral audit (#3068) Audit of deployment/production-config.mdx against develop (Lucee 7 + Adobe 2023 harness) found seven docs-wrong claims and a batch of stale source citations. All corrections re-verified against the current develop head (post-#3057/#3058 line shifts). - Empty reloadPassword does NOT disable ?reload= — only URL env-switching; a bare ?reload=true still restarts the app unauthenticated (refs #3062) - wheels dbmigrate latest -> wheels migrate latest (no dbmigrate verb) - csrfStore defaults to "session" unconditionally; cookie storage is an explicit opt-in (checklist 5 parenthetical was false) - flash storage selection cite: orm.cfm:57-64, not security.cfm:49-55 - redirectAfterReload cite: orm.cfm:26/:52-54 (also flips for maintenance), not security.cfm:43-45 - wheels doctor scope: structure/files/permissions/datasource only — it does not audit the production checklist items - Refresh all stale line refs (settings cascade, env-switch resolver, migrate-down gate, settings table, dotenv step 5, secure compare, rate limit, maintenance page, boot warning, auto-migrate gate) verify:docs passes (3 tagged blocks, 0 failed). Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Summary
?reload=production&password=...(and?reload=maintenance) — the exact flow in the security-hardening guide — 500'd on the post-restart cold start and the environment switch silently reverted. Two stacked root causes, both fixed here:redirectAfterReloadblock invendor/wheels/events/onapplicationstart.cfccalled a bare$location(url=..., addToken=false). That file is a plaincomponent {with no extends and no mixins, so$location()(defined invendor/wheels/Global.cfc:326) is not in scope — every other helper call in the file goes throughapplication.wo. Lucee threwNo matching function [$LOCATION] found, the application start aborted, and the engine discarded the half-started app.application.wo.$location(...),cflocation's abort fires whileonApplicationStartis still running, the engine discards the half-started application anyway, and the next request cold-starts fromconfig/environment.cfm— the switch still reverted (security log showed "Environment switched to 'production'" followed by development on the next probe).Fix: the block now stashes the stripped URL on the request scope (
request.wheels.redirectAfterReloadUrl) andEventMethods.$runOnRequestStart— which runs in the same request after the new application (including the switched environment) has been persisted — performs the$location()redirect, where it resolves via itswheels.Globalinheritance chain.$initializeRequestScope()is idempotent, so the stash survives the onApplicationStart → onRequestStart handoff.Why exactly production/maintenance broke:
events/init/orm.cfm:52-54auto-enablesredirectAfterReloadfor those two environments, and #3036 (issue #3030) made the restart redirect preservereload+passwordfor environment switches — which finally madeurl.reloadvisible to this block on the cold start. The bare call itself had been latent since the Dec 2024 lifecycle restructure (efb0b1ea8).Regression spec
vendor/wheels/tests/specs/events/OnAppStartBareHelperGuardSpec.cfc(modeled onBareCfabortGuardSpec):onapplicationstart.cfcfor ANY bare$helper(call that is neither defined in the file nor reached via a dotted receiver — guards the whole class of bug, not just$location. Line-anchored, comment-prefix skipping (no global comment-strip regex — that shape hangs Lucee 7). Verified RED against pre-fix source ($location at line 488), GREEN after.request.wheels.redirectAfterReloadUrl =in onapplicationstart.cfc;StructDelete(request.wheels, "redirectAfterReloadUrl")in EventMethods.cfc).wheels.events.EventMethodsresolves$location(Global inheritance), pinning the deferred call's receiver.Verification (Lucee 7 docker harness, port 61902, development +
set(reloadPassword="smokepw"))RED (develop @ ddeec99, pre-fix):
GET /?reload=production&password=smokepw→302preserving params → followed hop → HTTP 500 ("Something went wrong")wheels-errors.log:No matching function [$LOCATION] foundwheels_security.log:Environment switched to 'production' via URL+Reload accepted— the switch engaged, then died/wheels/info?format=json→ 200 (reverted to development)GREEN (this branch):
?reload=production&password=smokepwfollowed with-L: 2 hops, final200→404athttp://localhost:61902/—reload/passwordstripped from the final URL/wheels/info?format=json→ 404 (production);wheels_security.logshows the switch;wheels-errors.loghas no new$LOCATIONentries (only the timestamped RED-run pair)?reload=maintenance&password=smokepw: 2 hops → final 503 with the maintenance page ("Sorry, maintenance work is being performed"); subsequent requests stay 503set(redirectAfterReload=true)+?reload=true&password=smokepw: completes (302 → 200), no errors logged (acceptance criterion 4)/wheels/core/tests?db=sqlite&format=json&reload=true&password=smokepw): 4401 pass / 12 fail / 0 error — the 12 are exactly the known pre-existinginternal.testClientSpeccontainer artifacts. New guard spec: 3/3 pass.Adobe 2023 spot-check (port 61903):
db=sqlite): 4411 pass / 1 fail / 1 error — both red bundles (migrator.typedColumnDefaultsSpec,security.RouteTesterHardeningSpec) fail identically on pristine develop in the same container (re-verified with this branch's changes reverted), i.e. pre-existing baseline unrelated to this change. New guard spec: 3/3 pass on Adobe.public/Application.cfc's$buildRedirectUrl()(local.urlshadows the url scope — that is sibling issue Adobe CF: every password reload and URL env switch returns HTTP 500 — local.url shadows the url scope in $buildRedirectUrl (#3036 regression) #3053, a fix: make URL environment switching work through the app reload restart redirect #3036 regression owned by its own fix). Baseline requests on Adobe confirm the modifiedEventMethods.cfccompiles and runs (the new block is a guarded no-op without the stash). Once Adobe CF: every password reload and URL env switch returns HTTP 500 — local.url shadows the url scope in $buildRedirectUrl (#3036 regression) #3053 lands, this fix is what makes the post-restart leg of the Adobe flow survive.Fixes #3054
🤖 Generated with Claude Code