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..31c4dc39b7 --- /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. `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 d95590fc6b..48682e7758 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,44 @@ 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. + // 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) + && Len(url.reload) + && StructKeyExists(url, "password") + && 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, + expiresAt: DateAdd("n", 1, Now()) + }; + } + applicationStop(); location(url = local.redirectUrl, addToken = false); } @@ -390,6 +467,41 @@ 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. 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") + && !IsBoolean(url.reload) + && Len(url.reload) + && StructKeyExists(url, "password") + && StructKeyExists(application, "wheels") + && StructKeyExists(application.wheels, "reloadPassword") + && Len(application.wheels.reloadPassword) + && ( + !StructKeyExists(application.wheels, "allowEnvironmentSwitchViaUrl") + || application.wheels.allowEnvironmentSwitchViaUrl + ) + ) { + local.stripParams = "lock"; + } + if (StructKeyExists(cgi, "query_string") && Len(cgi.query_string)) { local.oldQueryString = ListToArray(cgi.query_string, "&"); local.newQueryString = []; @@ -399,7 +511,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..0e2e000ed5 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,44 @@ 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. + // 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) + && Len(url.reload) + && StructKeyExists(url, "password") + && 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, + expiresAt: DateAdd("n", 1, Now()) + }; + } + applicationStop(); location(url = local.redirectUrl, addToken = false); } @@ -329,6 +405,41 @@ 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. 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") + && !IsBoolean(url.reload) + && Len(url.reload) + && StructKeyExists(url, "password") + && StructKeyExists(application, "wheels") + && StructKeyExists(application.wheels, "reloadPassword") + && Len(application.wheels.reloadPassword) + && ( + !StructKeyExists(application.wheels, "allowEnvironmentSwitchViaUrl") + || application.wheels.allowEnvironmentSwitchViaUrl + ) + ) { + 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 +451,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..0e2e000ed5 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,44 @@ 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. + // 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) + && Len(url.reload) + && StructKeyExists(url, "password") + && 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, + expiresAt: DateAdd("n", 1, Now()) + }; + } + applicationStop(); location(url = local.redirectUrl, addToken = false); } @@ -329,6 +405,41 @@ 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. 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") + && !IsBoolean(url.reload) + && Len(url.reload) + && StructKeyExists(url, "password") + && StructKeyExists(application, "wheels") + && StructKeyExists(application.wheels, "reloadPassword") + && Len(application.wheels.reloadPassword) + && ( + !StructKeyExists(application.wheels, "allowEnvironmentSwitchViaUrl") + || application.wheels.allowEnvironmentSwitchViaUrl + ) + ) { + 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 +451,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..0b7b25cb89 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,44 @@ 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. + // 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) + && Len(url.reload) + && StructKeyExists(url, "password") + && 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, + expiresAt: DateAdd("n", 1, Now()) + }; + } + applicationStop(); location(url = local.redirectUrl, addToken = false); } @@ -399,18 +476,53 @@ 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. 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") + && !IsBoolean(url.reload) + && Len(url.reload) + && StructKeyExists(url, "password") + && StructKeyExists(application, "wheels") + && StructKeyExists(application.wheels, "reloadPassword") + && Len(application.wheels.reloadPassword) + && ( + !StructKeyExists(application.wheels, "allowEnvironmentSwitchViaUrl") + || application.wheels.allowEnvironmentSwitchViaUrl + ) + ) { + 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..01d1d5e520 --- /dev/null +++ b/vendor/wheels/tests/specs/cli/ReloadEnvironmentSwitchParitySpec.cfc @@ -0,0 +1,212 @@ +/** + * 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 four 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. + * 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 four 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("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); + 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); + } + + }); + + } + +}