Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 33 additions & 16 deletions src/mitm/dns/dnsConfig.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand All @@ -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.
Expand All @@ -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 {
Expand All @@ -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();
}

/**
Expand All @@ -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 "";
}
Expand Down Expand Up @@ -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);
Expand All @@ -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
);
Expand All @@ -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.
Expand All @@ -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 = " +
Expand All @@ -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}`);
Expand Down
3 changes: 2 additions & 1 deletion src/mitm/sudoGate.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os from "os";
import { isSudoPasswordRequired } from "./dns/dnsConfig.ts";
import { isRoot } from "./systemCommands.ts";

Expand All @@ -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();
Expand Down
5 changes: 4 additions & 1 deletion src/mitm/systemCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down
12 changes: 6 additions & 6 deletions tests/unit/dns-config-generic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
});

Expand All @@ -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"
);
Expand Down
195 changes: 195 additions & 0 deletions tests/unit/mitm-dns-win32-sudo-fold-11430.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});