fix(mitm): gate Agent Bridge DNS and Trust Cert on sudo password (#7938) (#7939)

* fix(mitm): gate Agent Bridge DNS and Trust Cert on sudo password (#7938)

Extend the #7865 sudo gate to setup wizard DNS, Start DNS, and Trust Cert.
Start server still runs without a password but skips privileged cert/DNS
steps instead of spawning sudo -S with an empty string. Add a shared sudo
password modal on the Agent Bridge page for DNS and trust-cert actions.

Fixes #7938

* fix(mitm): use .tsx extension for MitmSudoPasswordModal hook

JSX in a .ts file broke dashboard typecheck and ESLint on CI.

* fix(mitm): skip DNS teardown on stop when sudo password missing (#7938)

stopMitm() no longer invokes removeDNSEntry with an empty password when
the server was started in skip mode. The MITM process is still killed.

* refactor(mitm): extract privileged step helpers to satisfy file-size cap

manager.ts exceeded the 800-line cap after #7938 gates. Move DNS teardown
and the shared sudo skip runner into dedicated modules; behavior unchanged.
This commit is contained in:
Sulistyo Fajar Pratama
2026-07-21 21:50:09 +07:00
committed by GitHub
parent 567736fac5
commit 246b87f739
12 changed files with 529 additions and 87 deletions

View File

@@ -9,6 +9,7 @@ import { AgentBridgeMaintenanceCard } from "./components/AgentBridgeMaintenanceC
import { AgentList } from "./components/AgentList";
import { EmptyStateNoProviders } from "./components/EmptyStateNoProviders";
import { useAgentBridgeState } from "./hooks/useAgentBridgeState";
import { useMitmSudoPrompt, MitmSudoPasswordModal } from "./hooks/useMitmSudoPrompt";
import type { MitmTargetView } from "@/mitm/types";
import type { MappingRow } from "./components/ModelMappingTable";
@@ -78,38 +79,60 @@ export default function AgentBridgePageClient({
const [actionError, setActionError] = useState<string | null>(null);
const [certGuide, setCertGuide] = useState<CertManualGuide | null>(null);
const { runPrivileged, sudoModalProps } = useMitmSudoPrompt({
hasCachedPassword: data.serverState.hasCachedPassword === true,
needsSudoPassword: data.serverState.needsSudoPassword === true,
isWin: data.serverState.isWin === true,
});
const postServerAction = useCallback(
async (
action: "start" | "stop" | "restart" | "trust-cert" | "regenerate-cert",
sudoPassword?: string
) => {
const res = await fetch("/api/tools/agent-bridge/server", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
sudoPassword ? { action, sudoPassword } : { action }
),
});
const payload = (await res.json().catch(() => ({}))) as {
error?: { message?: string };
skippable?: boolean;
manualGuide?: CertManualGuide;
};
if (!res.ok) {
throw new Error(payload.error?.message ?? `HTTP ${res.status}`);
}
if (payload.skippable && payload.manualGuide) {
setCertGuide(payload.manualGuide);
} else if (action === "trust-cert") {
setCertGuide(null);
}
await refresh();
},
[refresh]
);
// ── Server actions ────────────────────────────────────────────────────────
const handleServerAction = useCallback(
async (action: "start" | "stop" | "restart" | "trust-cert" | "regenerate-cert") => {
setActionError(null);
try {
const res = await fetch("/api/tools/agent-bridge/server", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action }),
if (action === "trust-cert") {
await runPrivileged(async (password) => {
await postServerAction("trust-cert", password || undefined);
});
const payload = (await res.json().catch(() => ({}))) as {
error?: { message?: string };
skippable?: boolean;
manualGuide?: CertManualGuide;
};
if (!res.ok) {
throw new Error(payload.error?.message ?? `HTTP ${res.status}`);
}
// Cert couldn't be auto-installed (container / headless): not an error —
// surface the manual-install guide instead of blocking. (#4546)
if (payload.skippable && payload.manualGuide) {
setCertGuide(payload.manualGuide);
} else if (action === "trust-cert") {
setCertGuide(null);
}
await refresh();
return;
}
try {
await postServerAction(action);
} catch (err) {
setActionError(err instanceof Error ? err.message : "Unknown error");
}
},
[refresh]
[postServerAction, runPrivileged]
);
// ── Upstream CA ───────────────────────────────────────────────────────────
@@ -152,18 +175,27 @@ export default function AgentBridgePageClient({
async (agentId: string, enabled: boolean) => {
setActionError(null);
try {
const res = await fetch(`/api/tools/agent-bridge/agents/${agentId}/dns`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
await runPrivileged(async (password) => {
const res = await fetch(`/api/tools/agent-bridge/agents/${agentId}/dns`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
password ? { enabled, sudoPassword: password } : { enabled }
),
});
if (!res.ok) {
const payload = (await res.json().catch(() => ({}))) as {
error?: { message?: string };
};
throw new Error(payload.error?.message ?? `HTTP ${res.status}`);
}
await refresh();
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await refresh();
} catch (err) {
setActionError(err instanceof Error ? err.message : "Unknown error");
}
},
[refresh]
[refresh, runPrivileged]
);
// ── Mappings save ─────────────────────────────────────────────────────────
@@ -308,6 +340,8 @@ export default function AgentBridgePageClient({
</div>
</>
)}
<MitmSudoPasswordModal {...sudoModalProps} />
</div>
);
}

View File

@@ -0,0 +1,157 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Button, Input, Modal } from "@/shared/components";
export interface MitmSudoPasswordModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (password: string) => void;
busy?: boolean;
error?: string | null;
}
/**
* Shared sudo password modal for Agent Bridge privileged actions (#7938).
* Same UX as AgentBridgeMaintenanceCard Repair / Remove CA.
*/
export function MitmSudoPasswordModal({
isOpen,
onClose,
onConfirm,
busy = false,
error = null,
}: MitmSudoPasswordModalProps) {
const tCli = useTranslations("cliTools");
const [sudoPassword, setSudoPassword] = useState("");
const [localError, setLocalError] = useState<string | null>(null);
const handleClose = () => {
setSudoPassword("");
setLocalError(null);
onClose();
};
const handleConfirm = () => {
if (!sudoPassword.trim()) {
setLocalError(tCli("sudoPasswordRequiredError"));
return;
}
const password = sudoPassword;
setSudoPassword("");
setLocalError(null);
onConfirm(password);
};
const displayError = error ?? localError;
return (
<Modal isOpen={isOpen} onClose={handleClose} title={tCli("sudoPasswordRequiredTitle")} size="sm">
<div className="flex flex-col gap-4">
<div className="flex items-start gap-3 rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3">
<span className="material-symbols-outlined text-[20px] text-yellow-500">warning</span>
<p className="text-xs text-text-muted">{tCli("sudoPasswordHint")}</p>
</div>
<Input
type="password"
placeholder={tCli("enterSudoPassword")}
value={sudoPassword}
onChange={(event) => setSudoPassword(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !busy) handleConfirm();
}}
/>
{displayError && (
<div className="flex items-center gap-2 rounded bg-red-500/10 px-2 py-1.5 text-xs text-red-600">
<span className="material-symbols-outlined text-[14px]">error</span>
<span>{displayError}</span>
</div>
)}
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={handleClose} disabled={busy}>
{tCli("cancel")}
</Button>
<Button size="sm" onClick={handleConfirm} disabled={busy}>
{tCli("confirm")}
</Button>
</div>
</div>
</Modal>
);
}
interface UseMitmSudoPromptOptions {
hasCachedPassword: boolean;
needsSudoPassword: boolean;
isWin: boolean;
}
type PrivilegedRunner = (password: string) => Promise<void>;
/**
* Prompt for sudo when the server reports `needsSudoPassword` and none is cached.
*/
export function useMitmSudoPrompt({
hasCachedPassword,
needsSudoPassword,
isWin,
}: UseMitmSudoPromptOptions) {
const pendingRef = useRef<PrivilegedRunner | null>(null);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const [modalError, setModalError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const canRunWithoutPassword = isWin || hasCachedPassword || !needsSudoPassword;
const closePasswordModal = useCallback(() => {
setShowPasswordModal(false);
pendingRef.current = null;
setModalError(null);
}, []);
const runPrivileged = useCallback(
async (fn: PrivilegedRunner) => {
if (canRunWithoutPassword) {
await fn("");
return;
}
setModalError(null);
pendingRef.current = fn;
setShowPasswordModal(true);
},
[canRunWithoutPassword]
);
const confirmPassword = useCallback(async (password: string) => {
const action = pendingRef.current;
if (!action) return;
setShowPasswordModal(false);
pendingRef.current = null;
setBusy(true);
setModalError(null);
try {
await action(password);
} catch (err) {
pendingRef.current = action;
setModalError(err instanceof Error ? err.message : "Action failed");
setShowPasswordModal(true);
} finally {
setBusy(false);
}
}, []);
return {
runPrivileged,
sudoModalProps: {
isOpen: showPasswordModal,
onClose: closePasswordModal,
onConfirm: confirmPassword,
busy,
error: modalError,
},
};
}

View File

@@ -8,7 +8,12 @@
import { AgentBridgeDnsActionSchema } from "@/shared/schemas/agentBridge";
import { addDNSEntry, removeDNSEntry } from "@/mitm/dns/dnsConfig";
import { upsertAgentBridgeState } from "@/lib/db/agentBridgeState";
import { getCachedPassword } from "@/mitm/manager";
import { getCachedPassword, setCachedPassword } from "@/mitm/manager";
import {
isMitmSudoPasswordRequired,
normalizeMitmSudoPasswordInput,
resolveMitmSudoPassword,
} from "@/mitm/sudoGate";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { createErrorResponse } from "@/lib/api/errorResponse";
import { ALL_TARGETS } from "@/mitm/targets/index";
@@ -42,8 +47,14 @@ export async function POST(request: Request, { params }: Params): Promise<Respon
const { enabled } = parsed.data;
const raw = body as Record<string, unknown>;
const sudoPassword =
typeof raw.sudoPassword === "string" ? raw.sudoPassword : (getCachedPassword() ?? "");
const sudoPassword = resolveMitmSudoPassword(
typeof raw.sudoPassword === "string" ? raw.sudoPassword : undefined,
getCachedPassword()
);
if (isMitmSudoPasswordRequired(sudoPassword)) {
return createErrorResponse({ status: 400, message: "Missing sudoPassword" });
}
try {
if (enabled) {
@@ -52,6 +63,12 @@ export async function POST(request: Request, { params }: Params): Promise<Respon
await removeDNSEntry(sudoPassword, id);
}
const suppliedPassword =
typeof raw.sudoPassword === "string" ? normalizeMitmSudoPasswordInput(raw.sudoPassword) : "";
if (process.platform !== "win32" && suppliedPassword) {
setCachedPassword(suppliedPassword);
}
upsertAgentBridgeState({ agent_id: id, dns_enabled: enabled });
return Response.json({ ok: true, dns_enabled: enabled });

View File

@@ -42,8 +42,14 @@ export async function GET(): Promise<Response> {
export async function POST(request: Request): Promise<Response> {
const raw = await request.json().catch(() => ({}));
const parsed = CertTrustBodySchema.safeParse(raw);
const sudoPassword =
(parsed.success ? parsed.data.sudoPassword : undefined) ?? getCachedPassword() ?? "";
const sudoPassword = resolveMitmSudoPassword(
parsed.success ? parsed.data.sudoPassword : undefined,
getCachedPassword()
);
if (isMitmSudoPasswordRequired(sudoPassword)) {
return createErrorResponse({ status: 400, message: "Missing sudoPassword" });
}
try {
const crtPath = certPath();
@@ -55,6 +61,12 @@ export async function POST(request: Request): Promise<Response> {
}
const result = await installCertResult(sudoPassword, crtPath);
if (result.installed) {
const suppliedPassword = parsed.success
? normalizeMitmSudoPasswordInput(parsed.data.sudoPassword)
: "";
if (process.platform !== "win32" && suppliedPassword) {
setCachedPassword(suppliedPassword);
}
const trusted = await checkCertInstalled(crtPath);
return Response.json({ ok: true, trusted });
}

View File

@@ -10,6 +10,11 @@ import { getCachedPassword, setCachedPassword } from "@/mitm/manager";
import { installCertResult, checkCertInstalled } from "@/mitm/cert/install";
import { generateCert } from "@/mitm/cert/generate";
import { resolveMitmDataDir } from "@/mitm/dataDir";
import {
isMitmSudoPasswordRequired,
normalizeMitmSudoPasswordInput,
resolveMitmSudoPassword,
} from "@/mitm/sudoGate";
import path from "path";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { createErrorResponse } from "@/lib/api/errorResponse";
@@ -55,13 +60,17 @@ export async function POST(request: Request): Promise<Response> {
const { action } = parsed.data;
const raw = body as Record<string, unknown>;
const sudoPassword =
typeof raw.sudoPassword === "string" ? raw.sudoPassword : (getCachedPassword() ?? "");
const sudoPassword = resolveMitmSudoPassword(
typeof raw.sudoPassword === "string" ? raw.sudoPassword : undefined,
getCachedPassword()
);
const rawApiKey = typeof raw.apiKey === "string" ? raw.apiKey : "";
try {
if (action === "start") {
if (sudoPassword) setCachedPassword(sudoPassword);
const suppliedPassword =
typeof raw.sudoPassword === "string" ? normalizeMitmSudoPasswordInput(raw.sudoPassword) : "";
if (suppliedPassword) setCachedPassword(suppliedPassword);
const apiKey = await resolveRouterApiKey(rawApiKey);
const { startMitm } = await import("@/mitm/manager.runtime");
const result = await startMitm(apiKey, sudoPassword);
@@ -90,10 +99,17 @@ export async function POST(request: Request): Promise<Response> {
}
if (action === "trust-cert") {
if (isMitmSudoPasswordRequired(sudoPassword)) {
return createErrorResponse({ status: 400, message: "Missing sudoPassword" });
}
const certPath = path.join(resolveMitmDataDir(), "mitm", "server.crt");
const pwd = sudoPassword || getCachedPassword() || "";
const result = await installCertResult(pwd, certPath);
const result = await installCertResult(sudoPassword, certPath);
if (result.installed) {
const suppliedPassword =
typeof raw.sudoPassword === "string" ? normalizeMitmSudoPasswordInput(raw.sudoPassword) : "";
if (process.platform !== "win32" && suppliedPassword) {
setCachedPassword(suppliedPassword);
}
const trusted = await checkCertInstalled(certPath);
return Response.json({ ok: true, trusted });
}

View File

@@ -22,6 +22,8 @@ import {
performRepairSteps,
type RepairPlan,
} from "./repair.ts";
import { runPrivilegedMitmStep } from "./privilegedMitmStep.ts";
import { removeStopDnsEntries } from "./stopDnsTeardown.ts";
export { buildRepairPlan, collectManagedHosts, type RepairPlan };
@@ -520,30 +522,42 @@ async function startMitmInternal(
// so we start in "untrusted" mode and let the operator trust the CA by hand
// (mirrors the best-effort "continuing" pattern used for DNS below). (#4546)
let certTrusted = false;
try {
const certResult =
migrationDecision === "use-root-ca"
? await installCaCert(sudoPassword, certPath)
: await installCertResult(sudoPassword, certPath);
certTrusted = certResult.installed;
if (!certResult.installed) {
log.warn(
{ reason: certResult.reason },
"MITM cert not auto-trusted; bridge starting in skip mode (manual trust required)"
);
await runPrivilegedMitmStep(
sudoPassword,
"Skipping MITM cert trust — no sudo password available (#7938)",
async () => {
try {
const certResult =
migrationDecision === "use-root-ca"
? await installCaCert(sudoPassword, certPath)
: await installCertResult(sudoPassword, certPath);
certTrusted = certResult.installed;
if (!certResult.installed) {
log.warn(
{ reason: certResult.reason },
"MITM cert not auto-trusted; bridge starting in skip mode (manual trust required)"
);
}
} catch (err) {
log.error({ err }, "installCertResult threw unexpectedly (continuing without trusted cert)");
}
}
} catch (err) {
log.error({ err }, "installCertResult threw unexpectedly (continuing without trusted cert)");
}
);
// 3. Add DNS entries: Antigravity defaults + all agents with dns_enabled=true +
// all custom hosts with enabled=true. Best-effort — see provisionDnsEntries.
log.info("Adding DNS entries...");
try {
await provisionDnsEntries(sudoPassword);
} catch (err) {
log.error({ err }, "DNS provisioning threw unexpectedly (continuing)");
}
await runPrivilegedMitmStep(
sudoPassword,
"Skipping DNS provisioning — no sudo password available (#7938)",
async () => {
log.info("Adding DNS entries...");
try {
await provisionDnsEntries(sudoPassword);
} catch (err) {
log.error({ err }, "DNS provisioning threw unexpectedly (continuing)");
}
}
);
// 4. Start MITM server
log.info("Starting MITM server...");
@@ -670,31 +684,6 @@ async function startMitmInternal(
};
}
/**
* DNS teardown step of stopMitm() (#1809) — split out purely to keep
* stopMitm()'s own cyclomatic complexity under the repo's ratchet; behavior
* is unchanged from the original inline implementation.
*/
async function removeStopDnsEntries(
deps: {
removeDNSEntry: (sudoPassword: string) => Promise<void>;
removeDNSEntries: (hosts: string[], sudoPassword: string) => Promise<void>;
collectManagedHosts: () => string[];
},
sudoPassword: string
): Promise<void> {
log.info("Removing DNS entries...");
await deps.removeDNSEntry(sudoPassword);
try {
const managed = deps.collectManagedHosts();
if (managed.length > 0) {
await deps.removeDNSEntries(managed, sudoPassword);
}
} catch (err) {
log.error({ err }, "Failed to remove managed DNS entries during stop (continuing)");
}
}
/**
* Kill the MITM server process during stop — either the in-memory
* `serverProcess` handle or, if that's gone, the PID recorded in `PID_FILE`.
@@ -765,9 +754,12 @@ export async function stopMitm(
collectManagedHosts: _depsOverride?.collectManagedHosts ?? collectManagedHosts,
};
// 1. Remove DNS entries FIRST — see function doc + module doc above for why
// this must happen before the process kill (#1809, Gap 8).
await removeStopDnsEntries(deps, sudoPassword);
// 1. Remove DNS entries FIRST — see function doc + module doc above (#1809).
await runPrivilegedMitmStep(
sudoPassword,
"Skipping DNS teardown — no sudo password available (#7938)",
() => removeStopDnsEntries(deps, sudoPassword)
);
// 2. Kill server process (in-memory or from PID file)
await killMitmServerProcessOnStop();

View File

@@ -0,0 +1,17 @@
import { createLogger } from "@/shared/utils/logger.ts";
import { canRunPrivilegedMitmSteps } from "./sudoGate.ts";
const log = createLogger("mitm-manager");
/** Run a privileged MITM step when sudo is available; log and skip otherwise (#7938). */
export async function runPrivilegedMitmStep(
sudoPassword: string,
skipLog: string,
step: () => Promise<void>
): Promise<void> {
if (!canRunPrivilegedMitmSteps(sudoPassword)) {
log.info(skipLog);
return;
}
await step();
}

View File

@@ -0,0 +1,26 @@
import { createLogger } from "@/shared/utils/logger.ts";
const log = createLogger("mitm-manager");
export type StopDnsDeps = {
removeDNSEntry: (sudoPassword: string) => Promise<void>;
removeDNSEntries: (hosts: string[], sudoPassword: string) => Promise<void>;
collectManagedHosts: () => string[];
};
/** DNS teardown step of stopMitm() (#1809) — extracted for file-size ratchet. */
export async function removeStopDnsEntries(
deps: StopDnsDeps,
sudoPassword: string
): Promise<void> {
log.info("Removing DNS entries...");
await deps.removeDNSEntry(sudoPassword);
try {
const managed = deps.collectManagedHosts();
if (managed.length > 0) {
await deps.removeDNSEntries(managed, sudoPassword);
}
} catch (err) {
log.error({ err }, "Failed to remove managed DNS entries during stop (continuing)");
}
}

View File

@@ -28,3 +28,8 @@ export function isMitmSudoPasswordRequired(sudoPassword: string): boolean {
if (normalizeMitmSudoPasswordInput(sudoPassword)) return false;
return isSudoPasswordRequired();
}
/** Whether cert trust / DNS provisioning may run (inverse of the hard gate). */
export function canRunPrivilegedMitmSteps(sudoPassword: string): boolean {
return !isMitmSudoPasswordRequired(sudoPassword);
}

View File

@@ -0,0 +1,46 @@
/**
* #7938 — Trust Cert must gate empty sudo the same way as Remove CA / Repair.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { isSudoPasswordRequired } from "../../src/mitm/dns/dnsConfig.ts";
const certRoute = await import("../../src/app/api/tools/agent-bridge/cert/route.ts");
const serverRoute = await import("../../src/app/api/tools/agent-bridge/server/route.ts");
function skipWhenSudoNotRequired() {
if (process.platform === "win32") return true;
if (!isSudoPasswordRequired()) return true;
const isRootUser = !!(process.getuid && process.getuid() === 0);
return isRootUser;
}
test("POST /cert returns 400 Missing sudoPassword when sudo is required and none supplied", async () => {
if (skipWhenSudoNotRequired()) return;
const res = await certRoute.POST(
new Request("http://127.0.0.1/api/tools/agent-bridge/cert", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
})
);
assert.equal(res.status, 400);
const body = (await res.json()) as { error?: { message?: string } };
assert.match(body.error?.message ?? "", /Missing sudoPassword/);
});
test("POST /server trust-cert returns 400 Missing sudoPassword when sudo is required", async () => {
if (skipWhenSudoNotRequired()) return;
const res = await serverRoute.POST(
new Request("http://127.0.0.1/api/tools/agent-bridge/server", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ action: "trust-cert" }),
})
);
assert.equal(res.status, 400);
const body = (await res.json()) as { error?: { message?: string } };
assert.match(body.error?.message ?? "", /Missing sudoPassword/);
});

View File

@@ -0,0 +1,46 @@
/**
* #7938 — Agent Bridge DNS toggle must not spawn `sudo -S` with an empty password.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { isSudoPasswordRequired } from "../../src/mitm/dns/dnsConfig.ts";
const dnsRoute = await import(
"../../src/app/api/tools/agent-bridge/agents/[id]/dns/route.ts"
);
function makeDnsRequest(body: Record<string, unknown> = { enabled: true }) {
return new Request("http://127.0.0.1/api/tools/agent-bridge/agents/cursor/dns", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
test("POST .../[id]/dns returns 400 Missing sudoPassword when sudo is required and none supplied", async () => {
if (process.platform === "win32") return;
if (!isSudoPasswordRequired()) return;
const isRootUser = !!(process.getuid && process.getuid() === 0);
if (isRootUser) return;
const res = await dnsRoute.POST(makeDnsRequest({ enabled: true }), {
params: { id: "cursor" },
});
assert.equal(res.status, 400);
const body = (await res.json()) as { error?: { message?: string } };
assert.match(body.error?.message ?? "", /Missing sudoPassword/);
});
test("POST .../[id]/dns returns 400 for whitespace-only sudoPassword", async () => {
if (process.platform === "win32") return;
if (!isSudoPasswordRequired()) return;
const isRootUser = !!(process.getuid && process.getuid() === 0);
if (isRootUser) return;
const res = await dnsRoute.POST(makeDnsRequest({ enabled: true, sudoPassword: " " }), {
params: { id: "cursor" },
});
assert.equal(res.status, 400);
const body = (await res.json()) as { error?: { message?: string } };
assert.match(body.error?.message ?? "", /Missing sudoPassword/);
});

View File

@@ -0,0 +1,74 @@
/**
* #7938 — privileged MITM steps (cert trust, DNS) must be skippable when no sudo password.
*/
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 { EventEmitter } from "node:events";
import {
canRunPrivilegedMitmSteps,
isMitmSudoPasswordRequired,
} from "../../src/mitm/sudoGate.ts";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-mitm-sudo-gate-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const manager = await import("../../src/mitm/manager.ts");
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("canRunPrivilegedMitmSteps is false when isMitmSudoPasswordRequired is true", () => {
assert.equal(canRunPrivilegedMitmSteps("secret"), !isMitmSudoPasswordRequired("secret"));
});
test("canRunPrivilegedMitmSteps is false for empty password on POSIX sudo-required hosts", () => {
if (process.platform === "win32") return;
const isRootUser = !!(process.getuid && process.getuid() === 0);
if (isRootUser) return;
if (!isMitmSudoPasswordRequired("")) return;
assert.equal(canRunPrivilegedMitmSteps(""), false);
});
test("stopMitm skips DNS teardown without sudo password but still kills server (#7938)", async () => {
if (process.platform === "win32") return;
const isRootUser = !!(process.getuid && process.getuid() === 0);
if (isRootUser) return;
if (!isMitmSudoPasswordRequired("")) return;
const events: string[] = [];
const fakeProc = new EventEmitter() as EventEmitter & {
killed: boolean;
kill: (signal?: string) => boolean;
};
fakeProc.killed = false;
fakeProc.kill = (signal?: string) => {
events.push(`kill:${signal}`);
fakeProc.killed = true;
return true;
};
manager.__setServerProcessForTest(fakeProc as unknown as import("child_process").ChildProcess, 4242);
await manager.stopMitm("", {
removeDNSEntry: async () => {
events.push("removeDNSEntry");
},
removeDNSEntries: async () => {
events.push("removeDNSEntries");
},
collectManagedHosts: () => ["fake.example.test"],
});
assert.equal(
events.filter((event) => event.startsWith("remove")).length,
0,
"must not invoke DNS teardown with empty sudo password"
);
assert.ok(events.some((event) => event.startsWith("kill:")), "server process must still be stopped");
});