mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 17:12:27 +03:00
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:
@@ -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}`);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
195
tests/unit/mitm-dns-win32-sudo-fold-11430.test.ts
Normal file
195
tests/unit/mitm-dns-win32-sudo-fold-11430.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user