From 926502ed1a47710a59e8ca0e1d28923408cf4c52 Mon Sep 17 00:00:00 2001 From: Wenjix Date: Mon, 26 Oct 2026 23:10:36 -0700 Subject: [PATCH] fix(runtime-local): harden the Windows PowerShell launch path Three gaps shared one script boundary: - powershellLiteral() only escaped the ASCII quote, but Windows PowerShell accepts U+2018-U+201B as single-quote delimiters; a typographic quote in a command, path, log path or environment value ended the literal early and reopened script parsing inside the -EncodedCommand script. - $env: names interpolated into the same script were not validated, so a name holding a quote or operator injected statements. - powershell.exe spawned by bare name lets CreateProcess search the current directory first; a planted binary would run in place of the OS copy. Resolve the executable under SystemRoot instead (both here and in the credential-store-local launcher). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../credential-store-local/src/windows.ts | 14 ++++++++- packages/runtime-local/src/programs.ts | 30 +++++++++++++++---- .../test/powershell-literal.test.ts | 12 ++++++++ 3 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 packages/runtime-local/test/powershell-literal.test.ts diff --git a/packages/credential-store-local/src/windows.ts b/packages/credential-store-local/src/windows.ts index b6d531fa4..cfef83303 100644 --- a/packages/credential-store-local/src/windows.ts +++ b/packages/credential-store-local/src/windows.ts @@ -1,4 +1,5 @@ import { execFile } from "node:child_process"; +import { join } from "node:path"; import { fileURLToPath } from "node:url"; type WindowsCredentialResult = { @@ -9,6 +10,17 @@ type WindowsCredentialResult = { const windowsScript = fileURLToPath(new URL("../runtime/windows-credential.ps1", import.meta.url)); +/** + * The OS copy of Windows PowerShell. Resolved through SystemRoot because a bare name lets + * CreateProcess search the current directory first, where a planted powershell.exe would run. + */ +function windowsPowerShell(): string { + const systemRoot = process.env.SystemRoot; + return systemRoot === undefined + ? "powershell.exe" + : join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); +} + /** PasswordVault password field; OAuth JSON routinely exceeds this. */ export const windowsLockerPasswordLimit = 512; @@ -31,7 +43,7 @@ export function windowsCredential( ): Promise { if (secret !== undefined) assertWindowsLockerSecret(secret, account); return new Promise((resolve, reject) => { - const child = execFile("powershell.exe", [ + const child = execFile(windowsPowerShell(), [ "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", windowsScript, "-Operation", operation, ], { diff --git a/packages/runtime-local/src/programs.ts b/packages/runtime-local/src/programs.ts index 700b5b311..47c8e28ed 100644 --- a/packages/runtime-local/src/programs.ts +++ b/packages/runtime-local/src/programs.ts @@ -183,9 +183,24 @@ async function startDetached(start: ManagedProgramCommand, root: string, logFd: }); } -/** A PowerShell single-quoted literal, which escapes by doubling the quote and nothing else. */ -function powershellLiteral(value: string): string { - return `'${value.replaceAll("'", "''")}'`; +/** + * A PowerShell single-quoted literal, which escapes by doubling the quote and nothing else. + * Windows PowerShell accepts U+2018–U+201B as quote delimiters beside the ASCII quote, so an + * unescaped typographic quote would end the literal early and reopen script parsing. + */ +export function powershellLiteral(value: string): string { + return `'${value.replace(/['\u2018-\u201B]/gu, (quote) => quote + quote)}'`; +} + +/** + * The OS copy of Windows PowerShell. Resolved through SystemRoot because a bare name lets + * CreateProcess search the current directory first, where a planted powershell.exe would run. + */ +function windowsPowerShell(): string { + const systemRoot = process.env.SystemRoot; + return systemRoot === undefined + ? "powershell.exe" + : join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); } /** @@ -238,7 +253,12 @@ export async function startWithOwnConsole( ): Promise<{ readonly pid?: number; readonly detail?: string }> { const errorPath = processErrorLogPath(logPath); const environment = Object.entries(start.env ?? {}) - .map(([name, value]) => `$env:${name} = ${powershellLiteral(String(value))}`).join("\n"); + .map(([name, value]) => { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)) { + throw new Error(`Program environment name ${name} must be a portable variable name`); + } + return `$env:${name} = ${powershellLiteral(String(value))}`; + }).join("\n"); const argumentList = start.args.length === 0 ? "" : ` -ArgumentList ${powershellLiteral(windowsCommandLine(start.args))}`; @@ -255,7 +275,7 @@ export async function startWithOwnConsole( const shell = await new Promise<{ readonly out: string; readonly err: string }>((settle) => { const encoded = Buffer.from(script, "utf16le").toString("base64"); - const child = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], { + const child = spawn(windowsPowerShell(), ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], { windowsHide: true, stdio: ["ignore", "pipe", "pipe"], }); diff --git a/packages/runtime-local/test/powershell-literal.test.ts b/packages/runtime-local/test/powershell-literal.test.ts new file mode 100644 index 000000000..b4b125faf --- /dev/null +++ b/packages/runtime-local/test/powershell-literal.test.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { powershellLiteral } from "../src/programs.js"; + +test("PowerShell literals escape every quote form the tokenizer accepts", () => { + assert.equal(powershellLiteral("plain"), "'plain'"); + assert.equal(powershellLiteral("it's"), "'it''s'"); + // Windows PowerShell accepts U+2018–U+201B as quote delimiters too; each must + // double or the literal ends early and reopens script parsing. + assert.equal(powershellLiteral("it’s"), "'it’’s'"); + assert.equal(powershellLiteral("a‘b‚c‛d"), "'a‘‘b‚‚c‛‛d'"); +});