diff --git a/registry/coder/modules/windows-rdp/README.md b/registry/coder/modules/windows-rdp/README.md index 111e8d7fd..bc5b4d3de 100644 --- a/registry/coder/modules/windows-rdp/README.md +++ b/registry/coder/modules/windows-rdp/README.md @@ -15,7 +15,7 @@ Enable Remote Desktop + a web based client on Windows workspaces, powered by [de module "windows_rdp" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/windows-rdp/coder" - version = "1.3.0" + version = "1.3.1" agent_id = coder_agent.main.id } ``` @@ -32,7 +32,7 @@ module "windows_rdp" { module "windows_rdp" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/windows-rdp/coder" - version = "1.3.0" + version = "1.3.1" agent_id = coder_agent.main.id } ``` @@ -43,7 +43,7 @@ module "windows_rdp" { module "windows_rdp" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/windows-rdp/coder" - version = "1.3.0" + version = "1.3.1" agent_id = coder_agent.main.id } ``` @@ -54,7 +54,7 @@ module "windows_rdp" { module "windows_rdp" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/windows-rdp/coder" - version = "1.3.0" + version = "1.3.1" agent_id = coder_agent.main.id devolutions_gateway_version = "2025.2.2" # Specify a specific version } diff --git a/registry/coder/modules/windows-rdp/devolutions-patch.js b/registry/coder/modules/windows-rdp/devolutions-patch.js index 1f231ca3f..0d49d6534 100644 --- a/registry/coder/modules/windows-rdp/devolutions-patch.js +++ b/registry/coder/modules/windows-rdp/devolutions-patch.js @@ -47,8 +47,10 @@ * * All properties should be defined as placeholder templates in the form * VALUE_NAME. The Coder module, when spun up, should then run some logic to - * replace the template slots with actual values. These values should never - * change from within JavaScript itself. + * replace the template slots with actual values. The module JSON-escapes each + * value before injecting it, so characters like backslashes and quotes stay + * intact inside these string literals. These values should never change from + * within JavaScript itself. * * @satisfies {FormFieldEntries} */ diff --git a/registry/coder/modules/windows-rdp/main.test.ts b/registry/coder/modules/windows-rdp/main.test.ts index 80c09fd0d..c9086c44c 100644 --- a/registry/coder/modules/windows-rdp/main.test.ts +++ b/registry/coder/modules/windows-rdp/main.test.ts @@ -35,6 +35,31 @@ function findWindowsRdpScript(state: TerraformState): string | null { return null; } +/** + * Extracts the username and password the module injected into the JS patch + * file. + * + * The values are injected as JSON-escaped content inside double-quoted JS + * string literals, so the matched literals are parsed with JSON.parse to get + * the original values back. The regex stays verbose and pedantic on purpose: + * it validates the structure of the form entries object and, by only matching + * non-quote characters or escape pairs, it cannot overshoot into later content. + */ +function extractFormFieldValues(rdpScript: string): { + username?: string; + password?: string; +} { + const formEntryValuesRe = + /username:\s*\{[\s\S]*?value:\s*(?"(?:[^"\\]|\\.)*")[\s\S]*?password:\s*\{[\s\S]*?value:\s*(?"(?:[^"\\]|\\.)*")/; + + const groups = formEntryValuesRe.exec(rdpScript)?.groups ?? {}; + + return { + username: groups.username && JSON.parse(groups.username), + password: groups.password && JSON.parse(groups.password), + }; +} + /** * @todo It would be nice if we had a way to verify that the Devolutions root * HTML file is modified to include the import for the patched Coder script, @@ -69,27 +94,6 @@ describe("Web RDP", async () => { }); it("Injects Terraform's username and password into the JS patch file", async () => { - /** - * Using a regex as a quick-and-dirty way to get at the username and - * password values. - * - * Tried going through the trouble of extracting out the form entries - * variable from the main output, converting it from Prettier/JS-based JSON - * text to universal JSON text, and exposing it as a parsed JSON value. That - * got to be a bit too much, though. - * - * Regex is a little bit more verbose and pedantic than normal. Want to - * have some basic safety nets for validating the structure of the form - * entries variable after the JS file has had values injected. Even with all - * the wildcard classes set to lazy mode, we want to make sure that they - * don't overshoot and grab too much content. - * - * Written and tested via Regex101 - * @see {@link https://regex101.com/r/UMgQpv/2} - */ - const formEntryValuesRe = - /username:\s*\{[\s\S]*?value:\s*"(?[^"]+)"[\s\S]*?password:\s*\{[\s\S]*?value:\s*"(?[^"]+)"/; - // Test that things work with the default username/password const defaultState = await runTerraformApply( import.meta.dir, @@ -101,11 +105,10 @@ describe("Web RDP", async () => { const defaultRdpScript = findWindowsRdpScript(defaultState); expect(defaultRdpScript).toBeString(); - const defaultResultsGroup = - formEntryValuesRe.exec(defaultRdpScript ?? "")?.groups ?? {}; - - expect(defaultResultsGroup.username).toBe("Administrator"); - expect(defaultResultsGroup.password).toBe("coderRDP!"); + expect(extractFormFieldValues(defaultRdpScript ?? "")).toEqual({ + username: "Administrator", + password: "coderRDP!", + }); // Test that custom usernames/passwords are also forwarded correctly const customAdminUsername = "crouton"; @@ -122,10 +125,34 @@ describe("Web RDP", async () => { const customRdpScript = findWindowsRdpScript(customizedState); expect(customRdpScript).toBeString(); - const customResultsGroup = - formEntryValuesRe.exec(customRdpScript ?? "")?.groups ?? {}; + expect(extractFormFieldValues(customRdpScript ?? "")).toEqual({ + username: customAdminUsername, + password: customAdminPassword, + }); + }); + + it("Preserves special characters in the password", async () => { + // Covers the characters that break naive string interpolation in either the + // JS patch file or the PowerShell installation script. + const specialPassword = "N;JVO*U\\mL^a*P\"'`$&<>|{}[]%@:~"; + + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + admin_password: specialPassword, + }); - expect(customResultsGroup.username).toBe(customAdminUsername); - expect(customResultsGroup.password).toBe(customAdminPassword); + const rdpScript = findWindowsRdpScript(state); + expect(rdpScript).toBeString(); + + // The JS patch file must receive the password verbatim once parsed. + expect(extractFormFieldValues(rdpScript ?? "").password).toBe( + specialPassword, + ); + + // PowerShell single-quoted strings are literal, and a literal single quote + // is escaped by doubling it. + expect(rdpScript).toContain( + `Set-AdminPassword -adminPassword '${specialPassword.replaceAll("'", "''")}'`, + ); }); }); diff --git a/registry/coder/modules/windows-rdp/main.tf b/registry/coder/modules/windows-rdp/main.tf index 3c83d195b..c1035ce18 100644 --- a/registry/coder/modules/windows-rdp/main.tf +++ b/registry/coder/modules/windows-rdp/main.tf @@ -70,22 +70,37 @@ variable "devolutions_gateway_version" { description = "Version of Devolutions Gateway to install. Use 'latest' for the most recent version, or specify a version like '2025.3.2'." } +locals { + # The Devolutions patch script embeds these values inside double-quoted JS + # string literals. jsonencode escapes backslashes, quotes, control characters, + # and HTML-significant characters; the outer quotes are trimmed because the JS + # file supplies its own. + js_admin_username = trimsuffix(trimprefix(jsonencode(var.admin_username), "\""), "\"") + js_admin_password = trimsuffix(trimprefix(jsonencode(var.admin_password), "\""), "\"") + + # The installation script passes these values as PowerShell single-quoted + # strings, which are literal apart from the single quote itself. Doubling the + # single quotes keeps values containing $, backticks, or double quotes intact. + ps_admin_username = replace(var.admin_username, "'", "''") + ps_admin_password = replace(var.admin_password, "'", "''") +} + resource "coder_script" "windows-rdp" { agent_id = var.agent_id display_name = "windows-rdp" icon = "/icon/rdp.svg" script = templatefile("${path.module}/powershell-installation-script.tftpl", { - admin_username = var.admin_username - admin_password = var.admin_password + admin_username = local.ps_admin_username + admin_password = local.ps_admin_password devolutions_gateway_version = var.devolutions_gateway_version # Wanted to have this be in the powershell template file, but Terraform # doesn't allow recursive calls to the templatefile function. Have to feed # results of the JS template replace into the powershell template patch_file_contents = templatefile("${path.module}/devolutions-patch.js", { - CODER_USERNAME = var.admin_username - CODER_PASSWORD = var.admin_password + CODER_USERNAME = local.js_admin_username + CODER_PASSWORD = local.js_admin_password }) }) diff --git a/registry/coder/modules/windows-rdp/powershell-installation-script.tftpl b/registry/coder/modules/windows-rdp/powershell-installation-script.tftpl index 1657b878d..fdd5a1b92 100644 --- a/registry/coder/modules/windows-rdp/powershell-installation-script.tftpl +++ b/registry/coder/modules/windows-rdp/powershell-installation-script.tftpl @@ -6,9 +6,9 @@ function Set-AdminPassword { Import-Module Microsoft.PowerShell.LocalAccounts -ErrorAction SilentlyContinue # Set admin password - Get-LocalUser -Name "${admin_username}" | Set-LocalUser -Password (ConvertTo-SecureString -AsPlainText $adminPassword -Force) + Get-LocalUser -Name '${admin_username}' | Set-LocalUser -Password (ConvertTo-SecureString -AsPlainText $adminPassword -Force) # Enable admin user - Get-LocalUser -Name "${admin_username}" | Enable-LocalUser + Get-LocalUser -Name '${admin_username}' | Enable-LocalUser } function Configure-RDP { @@ -125,7 +125,7 @@ if ($isPatched -eq $null) { } } -Set-AdminPassword -adminPassword "${admin_password}" +Set-AdminPassword -adminPassword '${admin_password}' Configure-RDP Install-DevolutionsGateway Patch-Devolutions-HTML