From 3e98b50d276ffd635f2bafa3d08d2bbacb991e51 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Thu, 11 Jun 2026 12:38:38 -0700 Subject: [PATCH 1/2] fix: make URL environment switching work through the app reload restart redirect The only path that restarts a stock app is public/Application.cfc's reload gate: applicationStop() + a redirect built by $buildRedirectUrl(), which stripped reload, password, and lock from the query string. The framework's environment switch (wheels/events/onapplicationstart.cfc) needs URL.reload and URL.password present on the request that starts the new application, so ?reload= was a silent no-op through the stock flow. Three cooperating changes, applied to all four same-lineage copies (CLI app template, repo demo app, starter-app and tweet examples): 1. $buildRedirectUrl() preserves reload + password (still strips lock) when the reload value is an environment switch that can actually apply (non-boolean, non-empty, password supplied, reloadPassword configured). Plain ?reload=true keeps the strip-everything behavior. 2. The configured reloadPassword is handed across the applicationStop() boundary via a single-use, expiring server-scope entry consumed by onApplicationStart(). Without it the switch can never apply: the framework reads the password BEFORE config/settings.cfm is loaded, via carryover from the live application scope that applicationStop() destroys (verified live on Lucee 7 - preserving the parameters alone produced an endless 302 chain with the environment stuck). 3. onRequestStart() breaks the restart loop: once the requested environment is active, the gate is skipped and the request served normally. Trade-off: ?reload= is a no-op; use ?reload=true for a same-environment restart. A structural parity spec pins all three parts across the four copies. Fixes #3030 Signed-off-by: Peter Amiri --- .../3030-reload-envswitch-redirect.fixed.md | 1 + .../templates/app/public/Application.cfc | 91 ++++++++- examples/starter-app/public/Application.cfc | 90 ++++++++- examples/tweet/public/Application.cfc | 90 ++++++++- public/Application.cfc | 95 +++++++++- .../cli/ReloadEnvironmentSwitchParitySpec.cfc | 175 ++++++++++++++++++ 6 files changed, 536 insertions(+), 6 deletions(-) create mode 100644 changelog.d/3030-reload-envswitch-redirect.fixed.md create mode 100644 vendor/wheels/tests/specs/cli/ReloadEnvironmentSwitchParitySpec.cfc diff --git a/changelog.d/3030-reload-envswitch-redirect.fixed.md b/changelog.d/3030-reload-envswitch-redirect.fixed.md new file mode 100644 index 0000000000..be59fa0a93 --- /dev/null +++ b/changelog.d/3030-reload-envswitch-redirect.fixed.md @@ -0,0 +1 @@ +- URL environment switching (`?reload=&password=...`) now works through the app template's `applicationStop()` reload flow: the restart redirect preserves `reload` + `password` for environment switches (plain `?reload=true` still strips everything), the configured `reloadPassword` is handed across the restart via a single-use server-scope entry so the framework's switch code can verify it on the cold start, and the reload gate skips the restart once the requested environment is active so the redirect chain always terminates. Applied to all four `public/Application.cfc` copies (CLI app template, repo demo app, starter-app and tweet examples). Trade-off: `?reload=` is now a no-op — use `?reload=true` for a same-environment restart (#3030) diff --git a/cli/lucli/templates/app/public/Application.cfc b/cli/lucli/templates/app/public/Application.cfc index d95590fc6b..9a4585ab46 100644 --- a/cli/lucli/templates/app/public/Application.cfc +++ b/cli/lucli/templates/app/public/Application.cfc @@ -101,6 +101,29 @@ component output="false" { function onApplicationStart() { application.env = duplicate(this.env); + + // Consume the single-use reload-password handoff left by + // $handleRestartAppRequest() for environment-switch restarts (issue #3030). + // The framework's switch code in wheels/events/onapplicationstart.cfc runs + // before config/settings.cfm is loaded and gets the configured password via + // carryover from application.wheels.reloadPassword — which applicationStop() + // destroys. Seeding this.wheels.reloadPassword here restores that carryover + // on the post-restart cold start ($init copies this.wheels into + // application.wheels before the carryover check). + local.handoffKey = "$wheelsReloadPasswordHandoff_" & this.name; + if (StructKeyExists(server, local.handoffKey)) { + local.handoff = server[local.handoffKey]; + StructDelete(server, local.handoffKey); + if ( + IsStruct(local.handoff) + && StructKeyExists(local.handoff, "reloadPassword") + && StructKeyExists(local.handoff, "expiresAt") + && DateCompare(Now(), local.handoff.expiresAt) < 0 + ) { + this.wheels.reloadPassword = local.handoff.reloadPassword; + } + } + application.wheelsdi = new wheels.Injector("wheels.Bindings"); /* wheels/global object */ @@ -220,9 +243,25 @@ component output="false" { } } + // Loop-break for URL environment switches (issue #3030): $buildRedirectUrl() + // keeps ?reload=&password=... on the post-restart redirect so the + // framework's switch code (vendor/wheels/events/onapplicationstart.cfc) can see + // them on the request that starts the new application. When that redirected + // request arrives here the switch has already been applied, so firing another + // applicationStop() would redirect forever. If the requested environment is + // already active, skip the restart and serve the request normally. + // Trade-off: ?reload= is a no-op — use ?reload=true for a + // same-environment restart. + local.environmentSwitchAlreadyApplied = StructKeyExists(url, "reload") + && !IsBoolean(url.reload) + && StructKeyExists(application, "wheels") + && StructKeyExists(application.wheels, "environment") + && application.wheels.environment == url.reload; + // Reload application properly using applicationStop() if requested. if ( StructKeyExists(url, "reload") + && !local.environmentSwitchAlreadyApplied && ( !StructKeyExists(application, "wheels") || !StructKeyExists(application.wheels, "reloadPassword") || !Len(application.wheels.reloadPassword) @@ -377,6 +416,32 @@ component output="false" { public void function $handleRestartAppRequest() { local.redirectUrl = this.$buildRedirectUrl(); + + // Environment-switch restarts (?reload=) need the configured + // reloadPassword available when the NEW application starts: the switch code + // in wheels/events/onapplicationstart.cfc runs before config/settings.cfm is + // loaded and normally reads the password via carryover from the live + // application scope, which applicationStop() destroys. Hand it across the + // restart via a single-use, short-lived server-scope entry consumed by + // onApplicationStart() (issue #3030). The value is the app's own configured + // password (the request's password was already verified against it by the + // reload gate), and the server scope is only reachable by code running on + // this engine — the same trust domain as config/settings.cfm itself. + if ( + StructKeyExists(url, "reload") + && !IsBoolean(url.reload) + && Len(url.reload) + && StructKeyExists(url, "password") + && StructKeyExists(application, "wheels") + && StructKeyExists(application.wheels, "reloadPassword") + && Len(application.wheels.reloadPassword) + ) { + server["$wheelsReloadPasswordHandoff_" & this.name] = { + reloadPassword: application.wheels.reloadPassword, + expiresAt: DateAdd("n", 1, Now()) + }; + } + applicationStop(); location(url = local.redirectUrl, addToken = false); } @@ -390,6 +455,30 @@ component output="false" { local.url = cgi.script_name; } + // For a plain restart (?reload=true) every reload-related parameter is + // stripped so the redirected request cannot trigger another restart. For an + // environment switch (?reload=) the framework needs URL.reload + // and URL.password present on the request that starts the new application + // (vendor/wheels/events/onapplicationstart.cfc), so those two survive the + // redirect; the restart loop is broken in onRequestStart instead, which + // skips the restart once the requested environment is active (issue #3030). + // Only preserve when the switch can actually be applied (a non-empty + // reloadPassword is configured and the request carries a password) — + // otherwise the new application could never switch and the preserved + // parameters would redirect forever. + local.stripParams = "reload,password,lock"; + if ( + StructKeyExists(url, "reload") + && !IsBoolean(url.reload) + && Len(url.reload) + && StructKeyExists(url, "password") + && StructKeyExists(application, "wheels") + && StructKeyExists(application.wheels, "reloadPassword") + && Len(application.wheels.reloadPassword) + ) { + local.stripParams = "lock"; + } + if (StructKeyExists(cgi, "query_string") && Len(cgi.query_string)) { local.oldQueryString = ListToArray(cgi.query_string, "&"); local.newQueryString = []; @@ -399,7 +488,7 @@ component output="false" { local.keyValue = local.oldQueryString[local.i]; local.key = ListFirst(local.keyValue, "="); - if (!ListFindNoCase("reload,password,lock", local.key)) { + if (!ListFindNoCase(local.stripParams, local.key)) { ArrayAppend(local.newQueryString, local.keyValue); } } diff --git a/examples/starter-app/public/Application.cfc b/examples/starter-app/public/Application.cfc index 803f0f53e3..f25bcfb6c7 100644 --- a/examples/starter-app/public/Application.cfc +++ b/examples/starter-app/public/Application.cfc @@ -91,6 +91,28 @@ component output="false" { include "../config/app.cfm"; function onApplicationStart() { + // Consume the single-use reload-password handoff left by + // $handleRestartAppRequest() for environment-switch restarts (issue #3030). + // The framework's switch code in wheels/events/onapplicationstart.cfc runs + // before config/settings.cfm is loaded and gets the configured password via + // carryover from application.wheels.reloadPassword — which applicationStop() + // destroys. Seeding this.wheels.reloadPassword here restores that carryover + // on the post-restart cold start ($init copies this.wheels into + // application.wheels before the carryover check). + local.handoffKey = "$wheelsReloadPasswordHandoff_" & this.name; + if (StructKeyExists(server, local.handoffKey)) { + local.handoff = server[local.handoffKey]; + StructDelete(server, local.handoffKey); + if ( + IsStruct(local.handoff) + && StructKeyExists(local.handoff, "reloadPassword") + && StructKeyExists(local.handoff, "expiresAt") + && DateCompare(Now(), local.handoff.expiresAt) < 0 + ) { + this.wheels.reloadPassword = local.handoff.reloadPassword; + } + } + application.wheelsdi = new wheels.Injector("wheels.Bindings"); /* wheels/global object */ @@ -199,9 +221,25 @@ component output="false" { } } + // Loop-break for URL environment switches (issue #3030): $buildRedirectUrl() + // keeps ?reload=&password=... on the post-restart redirect so the + // framework's switch code (vendor/wheels/events/onapplicationstart.cfc) can see + // them on the request that starts the new application. When that redirected + // request arrives here the switch has already been applied, so firing another + // applicationStop() would redirect forever. If the requested environment is + // already active, skip the restart and serve the request normally. + // Trade-off: ?reload= is a no-op — use ?reload=true for a + // same-environment restart. + local.environmentSwitchAlreadyApplied = StructKeyExists(url, "reload") + && !IsBoolean(url.reload) + && StructKeyExists(application, "wheels") + && StructKeyExists(application.wheels, "environment") + && application.wheels.environment == url.reload; + // Reload application properly using applicationStop() if requested. if ( StructKeyExists(url, "reload") + && !local.environmentSwitchAlreadyApplied && ( !StructKeyExists(application, "wheels") || !StructKeyExists(application.wheels, "reloadPassword") || !Len(application.wheels.reloadPassword) @@ -315,6 +353,32 @@ component output="false" { public void function $handleRestartAppRequest() { local.redirectUrl = this.$buildRedirectUrl(); + + // Environment-switch restarts (?reload=) need the configured + // reloadPassword available when the NEW application starts: the switch code + // in wheels/events/onapplicationstart.cfc runs before config/settings.cfm is + // loaded and normally reads the password via carryover from the live + // application scope, which applicationStop() destroys. Hand it across the + // restart via a single-use, short-lived server-scope entry consumed by + // onApplicationStart() (issue #3030). The value is the app's own configured + // password (the request's password was already verified against it by the + // reload gate), and the server scope is only reachable by code running on + // this engine — the same trust domain as config/settings.cfm itself. + if ( + StructKeyExists(url, "reload") + && !IsBoolean(url.reload) + && Len(url.reload) + && StructKeyExists(url, "password") + && StructKeyExists(application, "wheels") + && StructKeyExists(application.wheels, "reloadPassword") + && Len(application.wheels.reloadPassword) + ) { + server["$wheelsReloadPasswordHandoff_" & this.name] = { + reloadPassword: application.wheels.reloadPassword, + expiresAt: DateAdd("n", 1, Now()) + }; + } + applicationStop(); location(url = local.redirectUrl, addToken = false); } @@ -329,6 +393,30 @@ component output="false" { local.url = cgi.script_name; } + // For a plain restart (?reload=true) every reload-related parameter is + // stripped so the redirected request cannot trigger another restart. For an + // environment switch (?reload=) the framework needs URL.reload + // and URL.password present on the request that starts the new application + // (vendor/wheels/events/onapplicationstart.cfc), so those two survive the + // redirect; the restart loop is broken in onRequestStart instead, which + // skips the restart once the requested environment is active (issue #3030). + // Only preserve when the switch can actually be applied (a non-empty + // reloadPassword is configured and the request carries a password) — + // otherwise the new application could never switch and the preserved + // parameters would redirect forever. + local.stripParams = "reload,password,lock"; + if ( + StructKeyExists(url, "reload") + && !IsBoolean(url.reload) + && Len(url.reload) + && StructKeyExists(url, "password") + && StructKeyExists(application, "wheels") + && StructKeyExists(application.wheels, "reloadPassword") + && Len(application.wheels.reloadPassword) + ) { + local.stripParams = "lock"; + } + // Process query string parameters, removing reload-related ones if (StructKeyExists(cgi, "query_string") && Len(cgi.query_string)) { local.oldQueryString = ListToArray(cgi.query_string, "&"); @@ -340,7 +428,7 @@ component output="false" { local.key = ListFirst(local.keyValue, "="); // Remove reload-related parameters - if (!ListFindNoCase("reload,password,lock", local.key)) { + if (!ListFindNoCase(local.stripParams, local.key)) { ArrayAppend(local.newQueryString, local.keyValue); } } diff --git a/examples/tweet/public/Application.cfc b/examples/tweet/public/Application.cfc index 803f0f53e3..f25bcfb6c7 100755 --- a/examples/tweet/public/Application.cfc +++ b/examples/tweet/public/Application.cfc @@ -91,6 +91,28 @@ component output="false" { include "../config/app.cfm"; function onApplicationStart() { + // Consume the single-use reload-password handoff left by + // $handleRestartAppRequest() for environment-switch restarts (issue #3030). + // The framework's switch code in wheels/events/onapplicationstart.cfc runs + // before config/settings.cfm is loaded and gets the configured password via + // carryover from application.wheels.reloadPassword — which applicationStop() + // destroys. Seeding this.wheels.reloadPassword here restores that carryover + // on the post-restart cold start ($init copies this.wheels into + // application.wheels before the carryover check). + local.handoffKey = "$wheelsReloadPasswordHandoff_" & this.name; + if (StructKeyExists(server, local.handoffKey)) { + local.handoff = server[local.handoffKey]; + StructDelete(server, local.handoffKey); + if ( + IsStruct(local.handoff) + && StructKeyExists(local.handoff, "reloadPassword") + && StructKeyExists(local.handoff, "expiresAt") + && DateCompare(Now(), local.handoff.expiresAt) < 0 + ) { + this.wheels.reloadPassword = local.handoff.reloadPassword; + } + } + application.wheelsdi = new wheels.Injector("wheels.Bindings"); /* wheels/global object */ @@ -199,9 +221,25 @@ component output="false" { } } + // Loop-break for URL environment switches (issue #3030): $buildRedirectUrl() + // keeps ?reload=&password=... on the post-restart redirect so the + // framework's switch code (vendor/wheels/events/onapplicationstart.cfc) can see + // them on the request that starts the new application. When that redirected + // request arrives here the switch has already been applied, so firing another + // applicationStop() would redirect forever. If the requested environment is + // already active, skip the restart and serve the request normally. + // Trade-off: ?reload= is a no-op — use ?reload=true for a + // same-environment restart. + local.environmentSwitchAlreadyApplied = StructKeyExists(url, "reload") + && !IsBoolean(url.reload) + && StructKeyExists(application, "wheels") + && StructKeyExists(application.wheels, "environment") + && application.wheels.environment == url.reload; + // Reload application properly using applicationStop() if requested. if ( StructKeyExists(url, "reload") + && !local.environmentSwitchAlreadyApplied && ( !StructKeyExists(application, "wheels") || !StructKeyExists(application.wheels, "reloadPassword") || !Len(application.wheels.reloadPassword) @@ -315,6 +353,32 @@ component output="false" { public void function $handleRestartAppRequest() { local.redirectUrl = this.$buildRedirectUrl(); + + // Environment-switch restarts (?reload=) need the configured + // reloadPassword available when the NEW application starts: the switch code + // in wheels/events/onapplicationstart.cfc runs before config/settings.cfm is + // loaded and normally reads the password via carryover from the live + // application scope, which applicationStop() destroys. Hand it across the + // restart via a single-use, short-lived server-scope entry consumed by + // onApplicationStart() (issue #3030). The value is the app's own configured + // password (the request's password was already verified against it by the + // reload gate), and the server scope is only reachable by code running on + // this engine — the same trust domain as config/settings.cfm itself. + if ( + StructKeyExists(url, "reload") + && !IsBoolean(url.reload) + && Len(url.reload) + && StructKeyExists(url, "password") + && StructKeyExists(application, "wheels") + && StructKeyExists(application.wheels, "reloadPassword") + && Len(application.wheels.reloadPassword) + ) { + server["$wheelsReloadPasswordHandoff_" & this.name] = { + reloadPassword: application.wheels.reloadPassword, + expiresAt: DateAdd("n", 1, Now()) + }; + } + applicationStop(); location(url = local.redirectUrl, addToken = false); } @@ -329,6 +393,30 @@ component output="false" { local.url = cgi.script_name; } + // For a plain restart (?reload=true) every reload-related parameter is + // stripped so the redirected request cannot trigger another restart. For an + // environment switch (?reload=) the framework needs URL.reload + // and URL.password present on the request that starts the new application + // (vendor/wheels/events/onapplicationstart.cfc), so those two survive the + // redirect; the restart loop is broken in onRequestStart instead, which + // skips the restart once the requested environment is active (issue #3030). + // Only preserve when the switch can actually be applied (a non-empty + // reloadPassword is configured and the request carries a password) — + // otherwise the new application could never switch and the preserved + // parameters would redirect forever. + local.stripParams = "reload,password,lock"; + if ( + StructKeyExists(url, "reload") + && !IsBoolean(url.reload) + && Len(url.reload) + && StructKeyExists(url, "password") + && StructKeyExists(application, "wheels") + && StructKeyExists(application.wheels, "reloadPassword") + && Len(application.wheels.reloadPassword) + ) { + local.stripParams = "lock"; + } + // Process query string parameters, removing reload-related ones if (StructKeyExists(cgi, "query_string") && Len(cgi.query_string)) { local.oldQueryString = ListToArray(cgi.query_string, "&"); @@ -340,7 +428,7 @@ component output="false" { local.key = ListFirst(local.keyValue, "="); // Remove reload-related parameters - if (!ListFindNoCase("reload,password,lock", local.key)) { + if (!ListFindNoCase(local.stripParams, local.key)) { ArrayAppend(local.newQueryString, local.keyValue); } } diff --git a/public/Application.cfc b/public/Application.cfc index 5956b95469..3b97c0d709 100644 --- a/public/Application.cfc +++ b/public/Application.cfc @@ -106,6 +106,29 @@ component output="false" { function onApplicationStart() { application.env = duplicate(this.env); + + // Consume the single-use reload-password handoff left by + // $handleRestartAppRequest() for environment-switch restarts (issue #3030). + // The framework's switch code in wheels/events/onapplicationstart.cfc runs + // before config/settings.cfm is loaded and gets the configured password via + // carryover from application.wheels.reloadPassword — which applicationStop() + // destroys. Seeding this.wheels.reloadPassword here restores that carryover + // on the post-restart cold start ($init copies this.wheels into + // application.wheels before the carryover check). + local.handoffKey = "$wheelsReloadPasswordHandoff_" & this.name; + if (StructKeyExists(server, local.handoffKey)) { + local.handoff = server[local.handoffKey]; + StructDelete(server, local.handoffKey); + if ( + IsStruct(local.handoff) + && StructKeyExists(local.handoff, "reloadPassword") + && StructKeyExists(local.handoff, "expiresAt") + && DateCompare(Now(), local.handoff.expiresAt) < 0 + ) { + this.wheels.reloadPassword = local.handoff.reloadPassword; + } + } + application.wheelsdi = new wheels.Injector("wheels.Bindings"); /* wheels/global object */ @@ -226,9 +249,25 @@ component output="false" { } } + // Loop-break for URL environment switches (issue #3030): $buildRedirectUrl() + // keeps ?reload=&password=... on the post-restart redirect so the + // framework's switch code (vendor/wheels/events/onapplicationstart.cfc) can see + // them on the request that starts the new application. When that redirected + // request arrives here the switch has already been applied, so firing another + // applicationStop() would redirect forever. If the requested environment is + // already active, skip the restart and serve the request normally. + // Trade-off: ?reload= is a no-op — use ?reload=true for a + // same-environment restart. + local.environmentSwitchAlreadyApplied = StructKeyExists(url, "reload") + && !IsBoolean(url.reload) + && StructKeyExists(application, "wheels") + && StructKeyExists(application.wheels, "environment") + && application.wheels.environment == url.reload; + // Reload application properly using applicationStop() if requested. if ( StructKeyExists(url, "reload") + && !local.environmentSwitchAlreadyApplied && ( !StructKeyExists(application, "wheels") || !StructKeyExists(application.wheels, "reloadPassword") || !Len(application.wheels.reloadPassword) @@ -385,6 +424,32 @@ component output="false" { public void function $handleRestartAppRequest() { local.redirectUrl = this.$buildRedirectUrl(); + + // Environment-switch restarts (?reload=) need the configured + // reloadPassword available when the NEW application starts: the switch code + // in wheels/events/onapplicationstart.cfc runs before config/settings.cfm is + // loaded and normally reads the password via carryover from the live + // application scope, which applicationStop() destroys. Hand it across the + // restart via a single-use, short-lived server-scope entry consumed by + // onApplicationStart() (issue #3030). The value is the app's own configured + // password (the request's password was already verified against it by the + // reload gate), and the server scope is only reachable by code running on + // this engine — the same trust domain as config/settings.cfm itself. + if ( + StructKeyExists(url, "reload") + && !IsBoolean(url.reload) + && Len(url.reload) + && StructKeyExists(url, "password") + && StructKeyExists(application, "wheels") + && StructKeyExists(application.wheels, "reloadPassword") + && Len(application.wheels.reloadPassword) + ) { + server["$wheelsReloadPasswordHandoff_" & this.name] = { + reloadPassword: application.wheels.reloadPassword, + expiresAt: DateAdd("n", 1, Now()) + }; + } + applicationStop(); location(url = local.redirectUrl, addToken = false); } @@ -399,18 +464,42 @@ component output="false" { local.url = cgi.script_name; } + // For a plain restart (?reload=true) every reload-related parameter is + // stripped so the redirected request cannot trigger another restart. For an + // environment switch (?reload=) the framework needs URL.reload + // and URL.password present on the request that starts the new application + // (vendor/wheels/events/onapplicationstart.cfc), so those two survive the + // redirect; the restart loop is broken in onRequestStart instead, which + // skips the restart once the requested environment is active (issue #3030). + // Only preserve when the switch can actually be applied (a non-empty + // reloadPassword is configured and the request carries a password) — + // otherwise the new application could never switch and the preserved + // parameters would redirect forever. + local.stripParams = "reload,password,lock"; + if ( + StructKeyExists(url, "reload") + && !IsBoolean(url.reload) + && Len(url.reload) + && StructKeyExists(url, "password") + && StructKeyExists(application, "wheels") + && StructKeyExists(application.wheels, "reloadPassword") + && Len(application.wheels.reloadPassword) + ) { + local.stripParams = "lock"; + } + // Process query string parameters, removing reload-related ones if (StructKeyExists(cgi, "query_string") && Len(cgi.query_string)) { local.oldQueryString = ListToArray(cgi.query_string, "&"); local.newQueryString = []; local.iEnd = ArrayLen(local.oldQueryString); - + for (local.i = 1; local.i <= local.iEnd; local.i++) { local.keyValue = local.oldQueryString[local.i]; local.key = ListFirst(local.keyValue, "="); - + // Remove reload-related parameters - if (!ListFindNoCase("reload,password,lock", local.key)) { + if (!ListFindNoCase(local.stripParams, local.key)) { ArrayAppend(local.newQueryString, local.keyValue); } } diff --git a/vendor/wheels/tests/specs/cli/ReloadEnvironmentSwitchParitySpec.cfc b/vendor/wheels/tests/specs/cli/ReloadEnvironmentSwitchParitySpec.cfc new file mode 100644 index 0000000000..28eef91165 --- /dev/null +++ b/vendor/wheels/tests/specs/cli/ReloadEnvironmentSwitchParitySpec.cfc @@ -0,0 +1,175 @@ +/** + * Regression for issue ##3030 — "app template: reload redirect strips the + * environment-switch params, making ?reload= a silent no-op". + * + * The only path that restarts a stock app is public/Application.cfc's reload + * gate: it calls applicationStop() and redirects via $buildRedirectUrl(), + * which used to strip reload, password, AND lock from the query string. But + * the framework's environment switch (wheels/events/onapplicationstart.cfc) + * needs URL.reload + URL.password present on the request that starts the new + * application, so the switch code was unreachable through the stock flow. + * + * The fix has three cooperating parts, and ALL FOUR same-lineage copies of + * public/Application.cfc must carry them: + * + * 1. $buildRedirectUrl() preserves reload + password (still strips lock) + * when the reload value is an environment switch (non-boolean, non-empty, + * password supplied, non-empty reloadPassword configured). Plain + * ?reload=true keeps the strip-everything behavior. + * 2. onRequestStart() breaks the restart loop: when the redirected request + * arrives and the requested environment is already active, the gate is + * skipped and the request is served normally. Trade-off: + * ?reload= is a no-op (use ?reload=true for a + * same-environment restart). + * 3. The configured reloadPassword is handed across the applicationStop() + * boundary via a single-use, short-lived server-scope entry + * ($handleRestartAppRequest stores it, onApplicationStart consumes it + * into this.wheels.reloadPassword). Without it the switch can never + * apply: the framework reads the password BEFORE config/settings.cfm is + * loaded, via carryover from the live application scope that + * applicationStop() destroys — verified live on Lucee 7, where the + * preserved parameters alone produced an endless 302 chain with the + * environment stuck on development. + * + * Structural spec (no runtime): reads each copy and asserts the three parts + * are wired. Modeled on ApplicationCfcInjectorAssignmentSpec.cfc. + */ +component extends="wheels.WheelsTest" { + + function run() { + + describe("reload environment-switch redirect parity (issue ##3030)", () => { + + // 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("preserves reload+password on environment-switch redirects in " & relPath, () => { + var absolute = repoRoot & "/" & relPath; + expect(fileExists(absolute)).toBeTrue("Missing file: " & absolute); + var content = fileRead(absolute); + + // Default strip list stays intact for boolean reloads... + expect( + reFind('local\.stripParams\s*=\s*"reload,password,lock";', content) > 0 + ).toBeTrue( + relPath & " must default stripParams to reload,password,lock so plain " + & "?reload=true keeps stripping everything (issue ##3030)." + ); + + // ...and narrows to lock-only for environment switches. + expect( + reFind('local\.stripParams\s*=\s*"lock";', content) > 0 + ).toBeTrue( + relPath & " must narrow stripParams to just lock for environment-switch " + & "redirects so URL.reload and URL.password reach the request that starts " + & "the new application (issue ##3030)." + ); + + // The strip filter must consult the computed list, not a literal. + expect( + content contains "ListFindNoCase(local.stripParams, local.key)" + ).toBeTrue( + relPath & " must filter the redirect query string against local.stripParams." + ); + expect( + content contains 'ListFindNoCase("reload,password,lock", local.key)' + ).toBeFalse( + relPath & " still hardcodes the reload,password,lock strip list in the " + & "query-string filter — environment-switch parameters would never survive " + & "the redirect (issue ##3030)." + ); + + // The narrowing is gated on a switch that can actually apply: + // non-boolean, non-empty reload value plus a supplied password + // and a configured reloadPassword. + expect( + reFind("!IsBoolean\(url\.reload\)", content) > 0 + ).toBeTrue( + relPath & " must treat only non-boolean reload values as environment switches." + ); + }); + + it("breaks the restart loop once the requested environment is active in " & relPath, () => { + var absolute = repoRoot & "/" & relPath; + expect(fileExists(absolute)).toBeTrue("Missing file: " & absolute); + var content = fileRead(absolute); + + expect( + reFind('local\.environmentSwitchAlreadyApplied\s*=\s*StructKeyExists\(url,\s*"reload"\)', content) > 0 + ).toBeTrue( + relPath & " must compute environmentSwitchAlreadyApplied before the reload " + & "gate (issue ##3030)." + ); + expect( + content contains "application.wheels.environment == url.reload" + ).toBeTrue( + relPath & " must compare the active environment against url.reload so the " + & "redirected request does not restart again (issue ##3030)." + ); + expect( + reFind("&&\s*!local\.environmentSwitchAlreadyApplied", content) > 0 + ).toBeTrue( + relPath & " must skip the applicationStop() gate when the requested " + & "environment is already active — without this the preserved parameters " + & "redirect forever because redirectAfterReload defaults to false " + & "(issue ##3030)." + ); + }); + + it("hands the reloadPassword across the applicationStop() boundary in " & relPath, () => { + var absolute = repoRoot & "/" & relPath; + expect(fileExists(absolute)).toBeTrue("Missing file: " & absolute); + var content = fileRead(absolute); + + // Store side ($handleRestartAppRequest): single-use server-scope + // entry holding the app's own configured password. + expect( + reFind('server\["\$wheelsReloadPasswordHandoff_"\s*&\s*this\.name\]\s*=\s*\{', content) > 0 + ).toBeTrue( + relPath & " must stash the configured reloadPassword in a server-scope " + & "handoff before applicationStop() — the framework's switch code runs " + & "before config/settings.cfm is loaded and otherwise has no password to " + & "verify against on the post-restart cold start (issue ##3030)." + ); + + // Consume side (onApplicationStart): single-use + expiry-guarded, + // seeded into this.wheels so the framework's carryover picks it up. + expect( + content contains "StructDelete(server, local.handoffKey)" + ).toBeTrue( + relPath & " must delete the handoff on first consumption (single-use)." + ); + expect( + content contains "DateCompare(Now(), local.handoff.expiresAt) < 0" + ).toBeTrue( + relPath & " must honor the handoff expiry so a stale entry is never applied." + ); + expect( + content contains "this.wheels.reloadPassword = local.handoff.reloadPassword;" + ).toBeTrue( + relPath & " must seed this.wheels.reloadPassword from the handoff so the " + & "framework's reloadPassword carryover works on the cold start " + & "(issue ##3030)." + ); + }); + + })(rel); + } + + }); + + } + +} From 75cd0fa4d1f9f158f13c3ced244711401206bc84 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Thu, 11 Jun 2026 13:09:01 -0700 Subject: [PATCH 2/2] fix: enforce allowEnvironmentSwitchViaUrl on the reload redirect env-switch path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #3030 fix preserved reload+password and handed the reloadPassword across applicationStop() for environment switches, but checked only reload/password on the preserve and handoff conditions. It never consulted application.wheels.allowEnvironmentSwitchViaUrl, which IS available pre-restart. After applicationStop() the framework cannot enforce the flag: the switch in wheels/events/onapplicationstart.cfc reads only the password, the revert needs oldEnvironment carryover that never exists on a cold start, and the production/testing/maintenance auto-disable runs only after the switch has already applied (and not at all once the environment has become development). Net effect: a production app became switchable into development with only the reload password — verified live on Lucee 7 (with the guard removed, a production app's /wheels/info flips from 404 to 200 after ?reload=development). Add the flag check to both the preserve () and handoff () conditions in all four same-lineage public/Application.cfc copies. A disallowed switch now degrades to the safe strip-all plain restart. A missing flag counts as allowed, matching the framework's carryover default, so plain ?reload=true and allowed switches are unaffected. Extend ReloadEnvironmentSwitchParitySpec with a fourth assertion pinning the guard on both paths across all four copies. Signed-off-by: Peter Amiri --- .../3030-reload-envswitch-redirect.fixed.md | 2 +- .../templates/app/public/Application.cfc | 25 ++++++++++- examples/starter-app/public/Application.cfc | 25 ++++++++++- examples/tweet/public/Application.cfc | 25 ++++++++++- public/Application.cfc | 25 ++++++++++- .../cli/ReloadEnvironmentSwitchParitySpec.cfc | 41 ++++++++++++++++++- 6 files changed, 136 insertions(+), 7 deletions(-) diff --git a/changelog.d/3030-reload-envswitch-redirect.fixed.md b/changelog.d/3030-reload-envswitch-redirect.fixed.md index be59fa0a93..31c4dc39b7 100644 --- a/changelog.d/3030-reload-envswitch-redirect.fixed.md +++ b/changelog.d/3030-reload-envswitch-redirect.fixed.md @@ -1 +1 @@ -- URL environment switching (`?reload=&password=...`) now works through the app template's `applicationStop()` reload flow: the restart redirect preserves `reload` + `password` for environment switches (plain `?reload=true` still strips everything), the configured `reloadPassword` is handed across the restart via a single-use server-scope entry so the framework's switch code can verify it on the cold start, and the reload gate skips the restart once the requested environment is active so the redirect chain always terminates. Applied to all four `public/Application.cfc` copies (CLI app template, repo demo app, starter-app and tweet examples). Trade-off: `?reload=` is now a no-op — use `?reload=true` for a same-environment restart (#3030) +- URL environment switching (`?reload=&password=...`) now works through the app template's `applicationStop()` reload flow: the restart redirect preserves `reload` + `password` for environment switches (plain `?reload=true` still strips everything), the configured `reloadPassword` is handed across the restart via a single-use server-scope entry so the framework's switch code can verify it on the cold start, and the reload gate skips the restart once the requested environment is active so the redirect chain always terminates. `allowEnvironmentSwitchViaUrl` is enforced pre-restart: when switching is disallowed — `set(allowEnvironmentSwitchViaUrl=false)` or the framework's production/testing/maintenance auto-disable — the parameters are stripped and the request degrades to a plain restart, preserving the existing hardening (the framework cannot enforce the flag itself after `applicationStop()` destroys its carryover state). Applied to all four `public/Application.cfc` copies (CLI app template, repo demo app, starter-app and tweet examples). Trade-off: `?reload=` is now a no-op — use `?reload=true` for a same-environment restart (#3030) diff --git a/cli/lucli/templates/app/public/Application.cfc b/cli/lucli/templates/app/public/Application.cfc index 9a4585ab46..48682e7758 100644 --- a/cli/lucli/templates/app/public/Application.cfc +++ b/cli/lucli/templates/app/public/Application.cfc @@ -427,6 +427,14 @@ component output="false" { // password (the request's password was already verified against it by the // reload gate), and the server scope is only reachable by code running on // this engine — the same trust domain as config/settings.cfm itself. + // Skipped when allowEnvironmentSwitchViaUrl is explicitly disabled (covers + // both set(allowEnvironmentSwitchViaUrl=false) and the framework's + // production/testing/maintenance auto-disable): after applicationStop() + // the framework cannot enforce the flag itself — its revert in + // wheels/events/onapplicationstart.cfc needs carryover state the restart + // destroys — so this pre-restart gate is the only place the off-switch + // holds. A missing flag counts as allowed, matching the framework's + // carryover default. if ( StructKeyExists(url, "reload") && !IsBoolean(url.reload) @@ -435,6 +443,10 @@ component output="false" { && StructKeyExists(application, "wheels") && StructKeyExists(application.wheels, "reloadPassword") && Len(application.wheels.reloadPassword) + && ( + !StructKeyExists(application.wheels, "allowEnvironmentSwitchViaUrl") + || application.wheels.allowEnvironmentSwitchViaUrl + ) ) { server["$wheelsReloadPasswordHandoff_" & this.name] = { reloadPassword: application.wheels.reloadPassword, @@ -465,7 +477,14 @@ component output="false" { // Only preserve when the switch can actually be applied (a non-empty // reloadPassword is configured and the request carries a password) — // otherwise the new application could never switch and the preserved - // parameters would redirect forever. + // parameters would redirect forever. The same goes for + // allowEnvironmentSwitchViaUrl: when switching is explicitly disallowed + // (set(allowEnvironmentSwitchViaUrl=false) or the framework's + // production/testing/maintenance auto-disable) the parameters are + // stripped and the request degrades to a plain restart — the framework + // cannot enforce the flag on the post-applicationStop() cold start, so + // it must be enforced here, pre-restart. A missing flag counts as + // allowed, matching the framework's carryover default. local.stripParams = "reload,password,lock"; if ( StructKeyExists(url, "reload") @@ -475,6 +494,10 @@ component output="false" { && StructKeyExists(application, "wheels") && StructKeyExists(application.wheels, "reloadPassword") && Len(application.wheels.reloadPassword) + && ( + !StructKeyExists(application.wheels, "allowEnvironmentSwitchViaUrl") + || application.wheels.allowEnvironmentSwitchViaUrl + ) ) { local.stripParams = "lock"; } diff --git a/examples/starter-app/public/Application.cfc b/examples/starter-app/public/Application.cfc index f25bcfb6c7..0e2e000ed5 100644 --- a/examples/starter-app/public/Application.cfc +++ b/examples/starter-app/public/Application.cfc @@ -364,6 +364,14 @@ component output="false" { // password (the request's password was already verified against it by the // reload gate), and the server scope is only reachable by code running on // this engine — the same trust domain as config/settings.cfm itself. + // Skipped when allowEnvironmentSwitchViaUrl is explicitly disabled (covers + // both set(allowEnvironmentSwitchViaUrl=false) and the framework's + // production/testing/maintenance auto-disable): after applicationStop() + // the framework cannot enforce the flag itself — its revert in + // wheels/events/onapplicationstart.cfc needs carryover state the restart + // destroys — so this pre-restart gate is the only place the off-switch + // holds. A missing flag counts as allowed, matching the framework's + // carryover default. if ( StructKeyExists(url, "reload") && !IsBoolean(url.reload) @@ -372,6 +380,10 @@ component output="false" { && StructKeyExists(application, "wheels") && StructKeyExists(application.wheels, "reloadPassword") && Len(application.wheels.reloadPassword) + && ( + !StructKeyExists(application.wheels, "allowEnvironmentSwitchViaUrl") + || application.wheels.allowEnvironmentSwitchViaUrl + ) ) { server["$wheelsReloadPasswordHandoff_" & this.name] = { reloadPassword: application.wheels.reloadPassword, @@ -403,7 +415,14 @@ component output="false" { // Only preserve when the switch can actually be applied (a non-empty // reloadPassword is configured and the request carries a password) — // otherwise the new application could never switch and the preserved - // parameters would redirect forever. + // parameters would redirect forever. The same goes for + // allowEnvironmentSwitchViaUrl: when switching is explicitly disallowed + // (set(allowEnvironmentSwitchViaUrl=false) or the framework's + // production/testing/maintenance auto-disable) the parameters are + // stripped and the request degrades to a plain restart — the framework + // cannot enforce the flag on the post-applicationStop() cold start, so + // it must be enforced here, pre-restart. A missing flag counts as + // allowed, matching the framework's carryover default. local.stripParams = "reload,password,lock"; if ( StructKeyExists(url, "reload") @@ -413,6 +432,10 @@ component output="false" { && StructKeyExists(application, "wheels") && StructKeyExists(application.wheels, "reloadPassword") && Len(application.wheels.reloadPassword) + && ( + !StructKeyExists(application.wheels, "allowEnvironmentSwitchViaUrl") + || application.wheels.allowEnvironmentSwitchViaUrl + ) ) { local.stripParams = "lock"; } diff --git a/examples/tweet/public/Application.cfc b/examples/tweet/public/Application.cfc index f25bcfb6c7..0e2e000ed5 100755 --- a/examples/tweet/public/Application.cfc +++ b/examples/tweet/public/Application.cfc @@ -364,6 +364,14 @@ component output="false" { // password (the request's password was already verified against it by the // reload gate), and the server scope is only reachable by code running on // this engine — the same trust domain as config/settings.cfm itself. + // Skipped when allowEnvironmentSwitchViaUrl is explicitly disabled (covers + // both set(allowEnvironmentSwitchViaUrl=false) and the framework's + // production/testing/maintenance auto-disable): after applicationStop() + // the framework cannot enforce the flag itself — its revert in + // wheels/events/onapplicationstart.cfc needs carryover state the restart + // destroys — so this pre-restart gate is the only place the off-switch + // holds. A missing flag counts as allowed, matching the framework's + // carryover default. if ( StructKeyExists(url, "reload") && !IsBoolean(url.reload) @@ -372,6 +380,10 @@ component output="false" { && StructKeyExists(application, "wheels") && StructKeyExists(application.wheels, "reloadPassword") && Len(application.wheels.reloadPassword) + && ( + !StructKeyExists(application.wheels, "allowEnvironmentSwitchViaUrl") + || application.wheels.allowEnvironmentSwitchViaUrl + ) ) { server["$wheelsReloadPasswordHandoff_" & this.name] = { reloadPassword: application.wheels.reloadPassword, @@ -403,7 +415,14 @@ component output="false" { // Only preserve when the switch can actually be applied (a non-empty // reloadPassword is configured and the request carries a password) — // otherwise the new application could never switch and the preserved - // parameters would redirect forever. + // parameters would redirect forever. The same goes for + // allowEnvironmentSwitchViaUrl: when switching is explicitly disallowed + // (set(allowEnvironmentSwitchViaUrl=false) or the framework's + // production/testing/maintenance auto-disable) the parameters are + // stripped and the request degrades to a plain restart — the framework + // cannot enforce the flag on the post-applicationStop() cold start, so + // it must be enforced here, pre-restart. A missing flag counts as + // allowed, matching the framework's carryover default. local.stripParams = "reload,password,lock"; if ( StructKeyExists(url, "reload") @@ -413,6 +432,10 @@ component output="false" { && StructKeyExists(application, "wheels") && StructKeyExists(application.wheels, "reloadPassword") && Len(application.wheels.reloadPassword) + && ( + !StructKeyExists(application.wheels, "allowEnvironmentSwitchViaUrl") + || application.wheels.allowEnvironmentSwitchViaUrl + ) ) { local.stripParams = "lock"; } diff --git a/public/Application.cfc b/public/Application.cfc index 3b97c0d709..0b7b25cb89 100644 --- a/public/Application.cfc +++ b/public/Application.cfc @@ -435,6 +435,14 @@ component output="false" { // password (the request's password was already verified against it by the // reload gate), and the server scope is only reachable by code running on // this engine — the same trust domain as config/settings.cfm itself. + // Skipped when allowEnvironmentSwitchViaUrl is explicitly disabled (covers + // both set(allowEnvironmentSwitchViaUrl=false) and the framework's + // production/testing/maintenance auto-disable): after applicationStop() + // the framework cannot enforce the flag itself — its revert in + // wheels/events/onapplicationstart.cfc needs carryover state the restart + // destroys — so this pre-restart gate is the only place the off-switch + // holds. A missing flag counts as allowed, matching the framework's + // carryover default. if ( StructKeyExists(url, "reload") && !IsBoolean(url.reload) @@ -443,6 +451,10 @@ component output="false" { && StructKeyExists(application, "wheels") && StructKeyExists(application.wheels, "reloadPassword") && Len(application.wheels.reloadPassword) + && ( + !StructKeyExists(application.wheels, "allowEnvironmentSwitchViaUrl") + || application.wheels.allowEnvironmentSwitchViaUrl + ) ) { server["$wheelsReloadPasswordHandoff_" & this.name] = { reloadPassword: application.wheels.reloadPassword, @@ -474,7 +486,14 @@ component output="false" { // Only preserve when the switch can actually be applied (a non-empty // reloadPassword is configured and the request carries a password) — // otherwise the new application could never switch and the preserved - // parameters would redirect forever. + // parameters would redirect forever. The same goes for + // allowEnvironmentSwitchViaUrl: when switching is explicitly disallowed + // (set(allowEnvironmentSwitchViaUrl=false) or the framework's + // production/testing/maintenance auto-disable) the parameters are + // stripped and the request degrades to a plain restart — the framework + // cannot enforce the flag on the post-applicationStop() cold start, so + // it must be enforced here, pre-restart. A missing flag counts as + // allowed, matching the framework's carryover default. local.stripParams = "reload,password,lock"; if ( StructKeyExists(url, "reload") @@ -484,6 +503,10 @@ component output="false" { && StructKeyExists(application, "wheels") && StructKeyExists(application.wheels, "reloadPassword") && Len(application.wheels.reloadPassword) + && ( + !StructKeyExists(application.wheels, "allowEnvironmentSwitchViaUrl") + || application.wheels.allowEnvironmentSwitchViaUrl + ) ) { local.stripParams = "lock"; } diff --git a/vendor/wheels/tests/specs/cli/ReloadEnvironmentSwitchParitySpec.cfc b/vendor/wheels/tests/specs/cli/ReloadEnvironmentSwitchParitySpec.cfc index 28eef91165..01d1d5e520 100644 --- a/vendor/wheels/tests/specs/cli/ReloadEnvironmentSwitchParitySpec.cfc +++ b/vendor/wheels/tests/specs/cli/ReloadEnvironmentSwitchParitySpec.cfc @@ -9,7 +9,7 @@ * needs URL.reload + URL.password present on the request that starts the new * application, so the switch code was unreachable through the stock flow. * - * The fix has three cooperating parts, and ALL FOUR same-lineage copies of + * The fix has four cooperating parts, and ALL FOUR same-lineage copies of * public/Application.cfc must carry them: * * 1. $buildRedirectUrl() preserves reload + password (still strips lock) @@ -30,8 +30,18 @@ * applicationStop() destroys — verified live on Lucee 7, where the * preserved parameters alone produced an endless 302 chain with the * environment stuck on development. + * 4. Both the preserve (1) and the handoff (3) honor + * allowEnvironmentSwitchViaUrl: when switching is explicitly disallowed — + * set(allowEnvironmentSwitchViaUrl=false) or the framework's + * production/testing/maintenance auto-disable — the request degrades to + * the strip-everything plain restart. This gate MUST live pre-restart in + * the template: after applicationStop() the framework cannot enforce the + * flag itself (the revert in wheels/events/onapplicationstart.cfc needs + * carryover state the restart destroys, and the cold-start default is + * allow). A missing flag counts as allowed, matching the framework's + * carryover default. * - * Structural spec (no runtime): reads each copy and asserts the three parts + * Structural spec (no runtime): reads each copy and asserts the four parts * are wired. Modeled on ApplicationCfcInjectorAssignmentSpec.cfc. */ component extends="wheels.WheelsTest" { @@ -128,6 +138,33 @@ component extends="wheels.WheelsTest" { ); }); + it("honors allowEnvironmentSwitchViaUrl on the preserve and handoff paths in " & relPath, () => { + var absolute = repoRoot & "/" & relPath; + expect(fileExists(absolute)).toBeTrue("Missing file: " & absolute); + var content = fileRead(absolute); + + // Both the redirect-preserve condition ($buildRedirectUrl) and the + // password-handoff condition ($handleRestartAppRequest) must consult + // the flag. After applicationStop() the framework cannot enforce it + // (the revert in wheels/events/onapplicationstart.cfc needs carryover + // state the restart destroys, and the cold-start default is allow), + // so this pre-restart gate is the only place the configured + // off-switch — including the production/testing/maintenance + // auto-disable — can hold. A disallowed switch must degrade to the + // strip-all plain restart, never preserve the parameters. + var flagGuard = '!StructKeyExists\(application\.wheels,\s*"allowEnvironmentSwitchViaUrl"\)\s*\|\|\s*application\.wheels\.allowEnvironmentSwitchViaUrl'; + expect( + ArrayLen(reMatch(flagGuard, content)) >= 2 + ).toBeTrue( + relPath & " must gate BOTH the reload+password preserve " + & "($buildRedirectUrl) and the reloadPassword handoff " + & "($handleRestartAppRequest) on allowEnvironmentSwitchViaUrl so " + & "set(allowEnvironmentSwitchViaUrl=false) and the production " + & "auto-disable degrade an environment switch to the strip-all " + & "plain restart (issues ##3030/##3031)." + ); + }); + it("hands the reloadPassword across the applicationStop() boundary in " & relPath, () => { var absolute = repoRoot & "/" & relPath; expect(fileExists(absolute)).toBeTrue("Missing file: " & absolute);