diff --git a/src/mitm/dns/dnsConfig.ts b/src/mitm/dns/dnsConfig.ts index 938eae5296..239407deae 100644 --- a/src/mitm/dns/dnsConfig.ts +++ b/src/mitm/dns/dnsConfig.ts @@ -1,9 +1,9 @@ import { execFileSync } from "child_process"; import fs from "fs"; +import os from "os"; import path from "path"; import { execFileWithPassword, - getErrorMessage, isRoot, quotePowerShell, runElevatedPowerShell, @@ -24,10 +24,27 @@ export function resolveHostsForAgent(agentId?: string): string[] { return target?.hosts ?? ANTIGRAVITY_HOSTS; } -const IS_WIN = process.platform === "win32"; -const HOSTS_FILE = IS_WIN - ? path.join(process.env.SystemRoot || "C:\\Windows", "System32", "drivers", "etc", "hosts") - : "/etc/hosts"; +// Runtime platform check. Turbopack constant-folds a module-load +// `process.platform` read to the Linux build machine and prunes the Windows +// branch from the published artifact, so Windows 11's native `sudo.exe` then +// sees POSIX `sudo -S` (#10293 / #11236 / #11430). `os.platform()` is a +// function call the bundler cannot fold. +function isWin32(): boolean { + return os.platform() === "win32"; +} + +function hostsFilePath(): string { + if (isWin32()) { + return path.join( + process.env.SystemRoot || "C:\\Windows", + "System32", + "drivers", + "etc", + "hosts" + ); + } + return "/etc/hosts"; +} export interface DnsCommandDependencies { execFileWithPassword?: typeof execFileWithPassword; @@ -47,7 +64,7 @@ function resolveCommandDependencies(deps?: DnsCommandDependencies) { * also report `false`, so callers can fall through to the no-elevation path. */ export function isSudoAvailable(): boolean { - if (IS_WIN) return true; + if (isWin32()) return true; try { // `which sudo` exits 0 when found, non-zero otherwise. Fixed args, no // shell expansion — safe per Hard Rule #13. @@ -64,7 +81,7 @@ export function isSudoAvailable(): boolean { * (minimal container), or `sudo -n true` succeeds (passwordless NOPASSWD). */ export function canRunSudoWithoutPassword(): boolean { - if (IS_WIN) return true; + if (isWin32()) return true; if (isRoot()) return true; if (!isSudoAvailable()) return true; try { @@ -83,7 +100,7 @@ export function canRunSudoWithoutPassword(): boolean { * False on Windows, root, missing-sudo containers, or NOPASSWD sudoers. */ export function isSudoPasswordRequired(): boolean { - return !IS_WIN && isSudoAvailable() && !canRunSudoWithoutPassword(); + return !isWin32() && isSudoAvailable() && !canRunSudoWithoutPassword(); } /** @@ -99,7 +116,7 @@ function dnsLines(hostname: string): string[] { */ function readHostsFile(): string { try { - return fs.readFileSync(HOSTS_FILE, "utf8"); + return fs.readFileSync(hostsFilePath(), "utf8"); } catch { return ""; } @@ -156,8 +173,8 @@ export async function addDNSEntries( if (missingEntries.length === 0) return; - if (IS_WIN) { - const psHostsFile = quotePowerShell(HOSTS_FILE); + if (isWin32()) { + const psHostsFile = quotePowerShell(hostsFilePath()); const psEntries = missingEntries.map((e) => quotePowerShell(e)).join(", "); const script = "Add-Content -LiteralPath " + psHostsFile + " -Value " + psEntries; await commands.runElevatedPowerShell(script); @@ -168,7 +185,7 @@ export async function addDNSEntries( const data = missingEntries.map((e) => `${e}\n`).join(""); await commands.execFileWithPassword( "sudo", - ["-S", "tee", "-a", HOSTS_FILE], + ["-S", "tee", "-a", hostsFilePath()], sudoPassword, data ); @@ -195,7 +212,7 @@ fs.writeFileSync(filePath, filtered.join("\\n").replace(/\\n*$/, "\\n")); /** * Remove /etc/hosts entries for every hostname in `hosts`. * Idempotent — silently skips hosts that are not present. - * Complies with Hard Rule #13: HOSTS_FILE and hostname are passed as argv, not interpolated. + * Complies with Hard Rule #13: hostsFilePath() and hostname are passed as argv, not interpolated. * * On Windows, all hostnames are filtered in a single elevated PowerShell * invocation so the user gets one UAC prompt instead of one per host. @@ -212,8 +229,8 @@ export async function removeDNSEntries( if (presentHosts.length === 0) return; - if (IS_WIN) { - const psHostsFile = quotePowerShell(HOSTS_FILE); + if (isWin32()) { + const psHostsFile = quotePowerShell(hostsFilePath()); const psTargets = presentHosts.map((h) => quotePowerShell(h)).join(", "); const script = "$hostsFile = " + @@ -235,7 +252,7 @@ export async function removeDNSEntries( for (const hostname of presentHosts) { await commands.execFileWithPassword( "sudo", - ["-S", process.execPath, "-e", REMOVE_HOSTS_ENTRY_SCRIPT, HOSTS_FILE, hostname], + ["-S", process.execPath, "-e", REMOVE_HOSTS_ENTRY_SCRIPT, hostsFilePath(), hostname], sudoPassword ); console.log(`[DNS] Removed entries for ${hostname}`); diff --git a/src/mitm/sudoGate.ts b/src/mitm/sudoGate.ts index 0daff25693..71d76c7750 100644 --- a/src/mitm/sudoGate.ts +++ b/src/mitm/sudoGate.ts @@ -1,3 +1,4 @@ +import os from "os"; import { isSudoPasswordRequired } from "./dns/dnsConfig.ts"; import { isRoot } from "./systemCommands.ts"; @@ -23,7 +24,7 @@ export function resolveMitmSudoPassword( * without sudo on PATH. */ export function isMitmSudoPasswordRequired(sudoPassword: string): boolean { - if (process.platform === "win32") return false; + if (os.platform() === "win32") return false; if (isRoot()) return false; if (normalizeMitmSudoPasswordInput(sudoPassword)) return false; return isSudoPasswordRequired(); diff --git a/src/mitm/systemCommands.ts b/src/mitm/systemCommands.ts index e73a72153d..ec991f3f44 100644 --- a/src/mitm/systemCommands.ts +++ b/src/mitm/systemCommands.ts @@ -27,12 +27,15 @@ export function isRoot(): boolean { * root, the underlying command is executed directly (same user, no elevation). * * Returns `false` on Windows — sudo is meaningless there (UAC path is used). + * Read `os.platform()` at call time: a literal `process.platform` is + * constant-folded to the Linux build host, so the published Windows artifact + * would probe and then spawn native `sudo.exe` with POSIX `-S` (#11430). * * `execFileSync` is invoked with a fixed-string `command` and `args`, * never user input, and `stdio: "ignore"` so the probe is silent. */ export function isSudoAvailable(): boolean { - if (process.platform === "win32") return false; + if (os.platform() === "win32") return false; try { // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process execFileSync("sh", ["-c", "command -v sudo"], { stdio: "ignore" }); diff --git a/tests/unit/dns-config-generic.test.ts b/tests/unit/dns-config-generic.test.ts index 44bf4ea142..a9441895ae 100644 --- a/tests/unit/dns-config-generic.test.ts +++ b/tests/unit/dns-config-generic.test.ts @@ -227,15 +227,15 @@ test("addDNSEntries: calls exec with array-form args (Hard Rule #13 pattern)", a // The tee invocation must use array form: args array contains HOSTS_FILE as // a string argument, never template-interpolated into a shell string. assert.ok( - src.includes('"-S", "tee", "-a", HOSTS_FILE'), - "addDNSEntries must pass HOSTS_FILE as an argv element, not interpolated" + src.includes('"-S", "tee", "-a", hostsFilePath()'), + "addDNSEntries must pass hostsFilePath() as an argv element, not interpolated" ); - // The remove invocation must pass HOSTS_FILE and hostname as process.argv, + // The remove invocation must pass the hosts path and hostname as process.argv, // not string-interpolated. assert.ok( - src.includes("REMOVE_HOSTS_ENTRY_SCRIPT, HOSTS_FILE, hostname"), - "removeDNSEntries must pass HOSTS_FILE and hostname as argv, not interpolated" + src.includes("REMOVE_HOSTS_ENTRY_SCRIPT, hostsFilePath(), hostname"), + "removeDNSEntries must pass hostsFilePath() and hostname as argv, not interpolated" ); }); @@ -253,7 +253,7 @@ test("addDNSEntries: entry passed as stdin data, not shell-interpolated", () => ); assert.ok( src.includes( - 'commands.execFileWithPassword(\n "sudo",\n ["-S", "tee", "-a", HOSTS_FILE],\n sudoPassword,\n data\n )' + 'commands.execFileWithPassword(\n "sudo",\n ["-S", "tee", "-a", hostsFilePath()],\n sudoPassword,\n data\n )' ), "entry data must be passed as stdin to tee, not interpolated in args" ); diff --git a/tests/unit/mitm-dns-win32-sudo-fold-11430.test.ts b/tests/unit/mitm-dns-win32-sudo-fold-11430.test.ts new file mode 100644 index 0000000000..3cf1457e56 --- /dev/null +++ b/tests/unit/mitm-dns-win32-sudo-fold-11430.test.ts @@ -0,0 +1,195 @@ +/** + * #11430 — Windows 11 native sudo.exe rejects POSIX `sudo -S` during MITM DNS + * provisioning and Repair. + * + * Same fold as #10293 / #11236: the published artifact is bundled on Linux, and + * Turbopack constant-folds a module-load `process.platform` read to "linux", + * pruning the PowerShell hosts-file branch. The POSIX path then spawns `sudo -S`, + * which Windows 11's opt-in sudo.exe rejects (`unexpected argument '-S'`). + * + * Invariant: DNS/sudo helpers must read `os.platform()` at call time. A source + * `process.platform` literal in these files would bring the fold back. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { mock } from "node:test"; +import { fileURLToPath } from "node:url"; +import { + addDNSEntries, + isSudoAvailable, + isSudoPasswordRequired, +} from "../../src/mitm/dns/dnsConfig.ts"; +import { isSudoAvailable as posixSudoAvailable } from "../../src/mitm/systemCommands.ts"; +import { isMitmSudoPasswordRequired } from "../../src/mitm/sudoGate.ts"; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); + +const GUARDED_FILES = [ + "src/mitm/dns/dnsConfig.ts", + "src/mitm/systemCommands.ts", + "src/mitm/sudoGate.ts", +]; + +function stripComments(source: string): string { + let out = ""; + let i = 0; + let inBlock = false; + let inLine = false; + let inString: string | null = null; + while (i < source.length) { + const ch = source[i]; + const next = source[i + 1]; + if (inLine) { + if (ch === "\n") { + inLine = false; + out += ch; + } else { + out += " "; + } + i++; + continue; + } + if (inBlock) { + if (ch === "*" && next === "/") { + inBlock = false; + out += " "; + i += 2; + continue; + } + out += ch === "\n" ? "\n" : " "; + i++; + continue; + } + if (inString) { + out += ch; + if (ch === "\\") { + out += next ?? ""; + i += 2; + continue; + } + if (ch === inString) inString = null; + i++; + continue; + } + if (ch === "/" && next === "/") { + inLine = true; + out += " "; + i += 2; + continue; + } + if (ch === "/" && next === "*") { + inBlock = true; + out += " "; + i += 2; + continue; + } + if (ch === '"' || ch === "'" || ch === "`") inString = ch; + out += ch; + i++; + } + return out; +} + +function findFoldableReads(source: string): string[] { + const stripped = stripComments(source); + const offenders: string[] = []; + stripped.split("\n").forEach((line, index) => { + if (line.includes("process.platform")) { + offenders.push(`L${index + 1}: ${source.split("\n")[index].trim()}`); + } + }); + return offenders; +} + +for (const relPath of GUARDED_FILES) { + test(`${relPath} has no build-foldable process.platform reads (#11430)`, () => { + const source = fs.readFileSync(path.join(REPO_ROOT, relPath), "utf8"); + const offenders = findFoldableReads(source); + assert.deepEqual( + offenders, + [], + `${relPath} must read os.platform() at call time instead of the ` + + `build-foldable process.platform literal. Offenders: ${offenders.join("; ")}` + ); + assert.match( + source, + /os\.platform\(\)/, + `${relPath} must call os.platform() so the Windows branch survives a Linux build` + ); + }); +} + +test("dnsConfig Windows branch is selected via isWin32()/os.platform(), not a module-load IS_WIN constant", () => { + const source = fs.readFileSync(path.join(REPO_ROOT, "src/mitm/dns/dnsConfig.ts"), "utf8"); + assert.match( + source, + /function isWin32\(\):\s*boolean\s*\{\s*return os\.platform\(\) === "win32";?\s*\}/ + ); + assert.doesNotMatch(source, /const IS_WIN\s*=/); + assert.match(source, /if \(isWin32\(\)\)/); + assert.match(source, /runElevatedPowerShell/); +}); + +test("addDNSEntries on win32 uses elevated PowerShell and never POSIX sudo -S (#11430)", async () => { + const previousSkip = process.env.OMNIROUTE_SKIP_DNS_WRITE; + delete process.env.OMNIROUTE_SKIP_DNS_WRITE; + const platformMock = mock.method(os, "platform", () => "win32" as NodeJS.Platform); + const execCalls: Array<{ command: string; args: string[] }> = []; + let powershellScript = ""; + try { + await addDNSEntries(["fold-test-11430.example.com"], "unused-password", { + execFileWithPassword: async (command, args) => { + execCalls.push({ command, args }); + return ""; + }, + runElevatedPowerShell: async (script) => { + powershellScript = script; + return ""; + }, + }); + assert.equal(execCalls.length, 0, "must not spawn POSIX sudo on Windows"); + assert.ok( + powershellScript.includes("Add-Content"), + `must use elevated PowerShell Add-Content, got: ${powershellScript.slice(0, 200)}` + ); + assert.ok( + powershellScript.includes("fold-test-11430.example.com"), + "PowerShell payload must include the missing host entry" + ); + } finally { + platformMock.mock.restore(); + if (previousSkip === undefined) delete process.env.OMNIROUTE_SKIP_DNS_WRITE; + else process.env.OMNIROUTE_SKIP_DNS_WRITE = previousSkip; + } +}); + +test("dnsConfig isSudoAvailable is true on win32 without probing POSIX sudo (#11430)", () => { + const platformMock = mock.method(os, "platform", () => "win32" as NodeJS.Platform); + try { + assert.equal(isSudoAvailable(), true); + assert.equal(isSudoPasswordRequired(), false); + } finally { + platformMock.mock.restore(); + } +}); + +test("systemCommands isSudoAvailable is false on win32 so sudo -S is never spawned (#11430)", () => { + const platformMock = mock.method(os, "platform", () => "win32" as NodeJS.Platform); + try { + assert.equal(posixSudoAvailable(), false); + } finally { + platformMock.mock.restore(); + } +}); + +test("isMitmSudoPasswordRequired is false on win32 even with an empty password (#11430)", () => { + const platformMock = mock.method(os, "platform", () => "win32" as NodeJS.Platform); + try { + assert.equal(isMitmSudoPasswordRequired(""), false); + } finally { + platformMock.mock.restore(); + } +});