diff --git a/src/app/(dashboard)/dashboard/tools/agent-bridge/AgentBridgePageClient.tsx b/src/app/(dashboard)/dashboard/tools/agent-bridge/AgentBridgePageClient.tsx index 5c0d32e568..a9b25217db 100644 --- a/src/app/(dashboard)/dashboard/tools/agent-bridge/AgentBridgePageClient.tsx +++ b/src/app/(dashboard)/dashboard/tools/agent-bridge/AgentBridgePageClient.tsx @@ -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(null); const [certGuide, setCertGuide] = useState(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({ )} + + ); } diff --git a/src/app/(dashboard)/dashboard/tools/agent-bridge/hooks/useMitmSudoPrompt.tsx b/src/app/(dashboard)/dashboard/tools/agent-bridge/hooks/useMitmSudoPrompt.tsx new file mode 100644 index 0000000000..1729e5af8e --- /dev/null +++ b/src/app/(dashboard)/dashboard/tools/agent-bridge/hooks/useMitmSudoPrompt.tsx @@ -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(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 ( + +
+
+ warning +

{tCli("sudoPasswordHint")}

+
+ + setSudoPassword(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && !busy) handleConfirm(); + }} + /> + + {displayError && ( +
+ error + {displayError} +
+ )} + +
+ + +
+
+
+ ); +} + +interface UseMitmSudoPromptOptions { + hasCachedPassword: boolean; + needsSudoPassword: boolean; + isWin: boolean; +} + +type PrivilegedRunner = (password: string) => Promise; + +/** + * Prompt for sudo when the server reports `needsSudoPassword` and none is cached. + */ +export function useMitmSudoPrompt({ + hasCachedPassword, + needsSudoPassword, + isWin, +}: UseMitmSudoPromptOptions) { + const pendingRef = useRef(null); + const [showPasswordModal, setShowPasswordModal] = useState(false); + const [modalError, setModalError] = useState(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, + }, + }; +} diff --git a/src/app/api/tools/agent-bridge/agents/[id]/dns/route.ts b/src/app/api/tools/agent-bridge/agents/[id]/dns/route.ts index fa7f5f6abc..74d3a09e2c 100644 --- a/src/app/api/tools/agent-bridge/agents/[id]/dns/route.ts +++ b/src/app/api/tools/agent-bridge/agents/[id]/dns/route.ts @@ -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; - 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 { export async function POST(request: Request): Promise { 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 { } 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 }); } diff --git a/src/app/api/tools/agent-bridge/server/route.ts b/src/app/api/tools/agent-bridge/server/route.ts index 84f4397cd3..bffa281380 100644 --- a/src/app/api/tools/agent-bridge/server/route.ts +++ b/src/app/api/tools/agent-bridge/server/route.ts @@ -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 { const { action } = parsed.data; const raw = body as Record; - 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 { } 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 }); } diff --git a/src/mitm/manager.ts b/src/mitm/manager.ts index 7c8fcb1a97..29303fa3fd 100644 --- a/src/mitm/manager.ts +++ b/src/mitm/manager.ts @@ -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; - removeDNSEntries: (hosts: string[], sudoPassword: string) => Promise; - collectManagedHosts: () => string[]; - }, - sudoPassword: string -): Promise { - 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(); diff --git a/src/mitm/privilegedMitmStep.ts b/src/mitm/privilegedMitmStep.ts new file mode 100644 index 0000000000..6f20ae5f12 --- /dev/null +++ b/src/mitm/privilegedMitmStep.ts @@ -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 +): Promise { + if (!canRunPrivilegedMitmSteps(sudoPassword)) { + log.info(skipLog); + return; + } + await step(); +} diff --git a/src/mitm/stopDnsTeardown.ts b/src/mitm/stopDnsTeardown.ts new file mode 100644 index 0000000000..c21b91fb29 --- /dev/null +++ b/src/mitm/stopDnsTeardown.ts @@ -0,0 +1,26 @@ +import { createLogger } from "@/shared/utils/logger.ts"; + +const log = createLogger("mitm-manager"); + +export type StopDnsDeps = { + removeDNSEntry: (sudoPassword: string) => Promise; + removeDNSEntries: (hosts: string[], sudoPassword: string) => Promise; + collectManagedHosts: () => string[]; +}; + +/** DNS teardown step of stopMitm() (#1809) — extracted for file-size ratchet. */ +export async function removeStopDnsEntries( + deps: StopDnsDeps, + sudoPassword: string +): Promise { + 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)"); + } +} diff --git a/src/mitm/sudoGate.ts b/src/mitm/sudoGate.ts index f5ff6c755e..0daff25693 100644 --- a/src/mitm/sudoGate.ts +++ b/src/mitm/sudoGate.ts @@ -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); +} diff --git a/tests/unit/agent-bridge-cert-trust-sudo-gate.test.ts b/tests/unit/agent-bridge-cert-trust-sudo-gate.test.ts new file mode 100644 index 0000000000..b70ea5142c --- /dev/null +++ b/tests/unit/agent-bridge-cert-trust-sudo-gate.test.ts @@ -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/); +}); diff --git a/tests/unit/agent-bridge-dns-sudo-gate.test.ts b/tests/unit/agent-bridge-dns-sudo-gate.test.ts new file mode 100644 index 0000000000..d84127086e --- /dev/null +++ b/tests/unit/agent-bridge-dns-sudo-gate.test.ts @@ -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 = { 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/); +}); diff --git a/tests/unit/mitm-privileged-steps-sudo-gate.test.ts b/tests/unit/mitm-privileged-steps-sudo-gate.test.ts new file mode 100644 index 0000000000..30701c7ad3 --- /dev/null +++ b/tests/unit/mitm-privileged-steps-sudo-gate.test.ts @@ -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"); +});