fix(mitm): keep Windows DNS elevation off the POSIX sudo path (#11430) (#11542)

Merged via /merge-batch (lote 2026-08-26, v3.8.51). Boarded no worktree combinado junto com outras ~30 PRs; validação única: typecheck/complexity/cognitive-complexity/changelog-integrity verdes, file-size rebaseado onde necessário (crescimento legítimo), lint com os mesmos 228 achados pré-existentes confirmados via sonda contra o tip puro (não introduzidos por este lote), e ~370 testes focados (unit + vitest) passando. Obrigado pela contribuição.
This commit is contained in:
Burak Keskin
2026-08-26 14:11:26 +03:00
committed by GitHub
parent b7d91a6b6e
commit 4914954866
5 changed files with 240 additions and 24 deletions

View File

@@ -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}`);

View File

@@ -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();

View File

@@ -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" });

View File

@@ -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"
);

View File

@@ -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();
}
});