fix(mitm): gate sudo prompts on server platform (#822)

This commit is contained in:
Rezky Hamid
2026-05-03 15:00:43 +07:00
committed by GitHub
parent a463ee00ff
commit e40f7ffb98
7 changed files with 62 additions and 37 deletions

View File

@@ -88,11 +88,12 @@ export default function AntigravityToolCard({
} }
}; };
// Windows uses UAC dialog, no sudo needed // MITM elevation is decided by the server OS, not by this browser's OS.
const isWindows = typeof navigator !== "undefined" && navigator.userAgent?.includes("Windows"); const serverIsWindows = status?.isWin === true;
const canRunWithoutPassword = serverIsWindows || status?.hasCachedPassword || status?.needsSudoPassword === false;
const handleStart = () => { const handleStart = () => {
if (isWindows || status?.hasCachedPassword) { if (canRunWithoutPassword) {
doStart(""); doStart("");
} else { } else {
setShowPasswordModal(true); setShowPasswordModal(true);
@@ -101,7 +102,7 @@ export default function AntigravityToolCard({
}; };
const handleStop = () => { const handleStop = () => {
if (isWindows || status?.hasCachedPassword) { if (canRunWithoutPassword) {
doStop(""); doStop("");
} else { } else {
setShowPasswordModal(true); setShowPasswordModal(true);
@@ -385,7 +386,7 @@ export default function AntigravityToolCard({
)} )}
{/* Windows admin warning */} {/* Windows admin warning */}
{!isRunning && isWindows && ( {!isRunning && serverIsWindows && (
<div className="flex items-center gap-2 px-2 py-1.5 rounded text-xs bg-yellow-500/10 text-yellow-600 border border-yellow-500/20"> <div className="flex items-center gap-2 px-2 py-1.5 rounded text-xs bg-yellow-500/10 text-yellow-600 border border-yellow-500/20">
<span className="material-symbols-outlined text-[14px]">warning</span> <span className="material-symbols-outlined text-[14px]">warning</span>
<span>Windows: Run terminal (9Router) as Administrator to enable MITM</span> <span>Windows: Run terminal (9Router) as Administrator to enable MITM</span>

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect, useCallback } from "react";
import { Card, Button, Badge, Input } from "@/shared/components"; import { Card, Button, Badge, Input } from "@/shared/components";
const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128"; const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128";
@@ -14,26 +14,17 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [showPasswordModal, setShowPasswordModal] = useState(false); const [showPasswordModal, setShowPasswordModal] = useState(false);
const [sudoPassword, setSudoPassword] = useState(""); const [sudoPassword, setSudoPassword] = useState("");
const [selectedApiKey, setSelectedApiKey] = useState(""); const [selectedApiKey, setSelectedApiKey] = useState(() => apiKeys?.[0]?.key || "");
const [pendingAction, setPendingAction] = useState(null); const [pendingAction, setPendingAction] = useState(null);
const [modalError, setModalError] = useState(null); const [modalError, setModalError] = useState(null);
const [actionError, setActionError] = useState(null); const [actionError, setActionError] = useState(null);
const [mitmRouterBaseUrl, setMitmRouterBaseUrl] = useState(DEFAULT_MITM_ROUTER_BASE); const [mitmRouterBaseUrl, setMitmRouterBaseUrl] = useState(DEFAULT_MITM_ROUTER_BASE);
const isWindows = typeof navigator !== "undefined" && navigator.userAgent?.includes("Windows"); const serverIsWindows = status?.isWin === true;
const canRunWithoutPassword = serverIsWindows || status?.hasCachedPassword || status?.needsSudoPassword === false;
const isAdmin = status?.isAdmin !== false; const isAdmin = status?.isAdmin !== false;
useEffect(() => { const fetchStatus = useCallback(async () => {
if (apiKeys?.length > 0 && !selectedApiKey) {
setSelectedApiKey(apiKeys[0].key);
}
}, [apiKeys, selectedApiKey]);
useEffect(() => {
fetchStatus();
}, []);
const fetchStatus = async () => {
try { try {
const res = await fetch("/api/cli-tools/antigravity-mitm"); const res = await fetch("/api/cli-tools/antigravity-mitm");
if (res.ok) { if (res.ok) {
@@ -47,11 +38,17 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
} catch { } catch {
setStatus({ running: false, certExists: false, dnsStatus: {} }); setStatus({ running: false, certExists: false, dnsStatus: {} });
} }
}; }, [onStatusChange]);
useEffect(() => {
queueMicrotask(() => {
fetchStatus();
});
}, [fetchStatus]);
const handleAction = (action) => { const handleAction = (action) => {
setActionError(null); setActionError(null);
if (isWindows || status?.hasCachedPassword) { if (canRunWithoutPassword) {
doAction(action, ""); doAction(action, "");
} else { } else {
setPendingAction(action); setPendingAction(action);
@@ -219,7 +216,7 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
) : ( ) : (
<button <button
onClick={() => handleAction("start")} onClick={() => handleAction("start")}
disabled={loading || (isWindows && !isAdmin)} disabled={loading || (serverIsWindows && !isAdmin)}
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-primary/30 bg-primary/10 px-4 py-2 text-xs font-medium text-primary transition-colors hover:bg-primary/20 disabled:opacity-50 sm:w-auto sm:py-1.5" className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-primary/30 bg-primary/10 px-4 py-2 text-xs font-medium text-primary transition-colors hover:bg-primary/20 disabled:opacity-50 sm:w-auto sm:py-1.5"
> >
<span className="material-symbols-outlined text-[16px]">play_circle</span> <span className="material-symbols-outlined text-[16px]">play_circle</span>
@@ -240,7 +237,7 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
)} )}
{/* Windows admin warning */} {/* Windows admin warning */}
{isWindows && !isAdmin && ( {serverIsWindows && !isAdmin && (
<div className="flex items-center gap-2 px-2 py-1.5 rounded text-xs bg-red-500/10 text-red-600 border border-red-500/20"> <div className="flex items-center gap-2 px-2 py-1.5 rounded text-xs bg-red-500/10 text-red-600 border border-red-500/20">
<span className="material-symbols-outlined text-[14px]">shield_lock</span> <span className="material-symbols-outlined text-[14px]">shield_lock</span>
<span>Administrator required restart 9Router as Administrator to use MITM</span> <span>Administrator required restart 9Router as Administrator to use MITM</span>

View File

@@ -18,6 +18,7 @@ export default function MitmToolCard({
serverRunning, serverRunning,
dnsActive, dnsActive,
hasCachedPassword, hasCachedPassword,
needsSudoPassword,
apiKeys, apiKeys,
activeProviders, activeProviders,
hasActiveProviders, hasActiveProviders,
@@ -36,7 +37,7 @@ export default function MitmToolCard({
const [currentEditingAlias, setCurrentEditingAlias] = useState(null); const [currentEditingAlias, setCurrentEditingAlias] = useState(null);
const mitmHosts = TOOL_HOSTS[tool.id] ?? []; const mitmHosts = TOOL_HOSTS[tool.id] ?? [];
const isWindows = typeof navigator !== "undefined" && navigator.userAgent?.includes("Windows"); const canRunWithoutPassword = hasCachedPassword || needsSudoPassword === false;
useEffect(() => { useEffect(() => {
if (isExpanded) loadSavedMappings(); if (isExpanded) loadSavedMappings();
@@ -85,7 +86,7 @@ export default function MitmToolCard({
const handleDnsToggle = () => { const handleDnsToggle = () => {
if (!serverRunning) return; if (!serverRunning) return;
const action = dnsActive ? "disable" : "enable"; const action = dnsActive ? "disable" : "enable";
if (isWindows || hasCachedPassword) { if (canRunWithoutPassword) {
doDnsAction(action, ""); doDnsAction(action, "");
} else { } else {
setPendingDnsAction(action); setPendingDnsAction(action);

View File

@@ -98,6 +98,7 @@ export default function MitmPageClient() {
serverRunning={mitmStatus.running} serverRunning={mitmStatus.running}
dnsActive={mitmStatus.dnsStatus?.[toolId] || false} dnsActive={mitmStatus.dnsStatus?.[toolId] || false}
hasCachedPassword={mitmStatus.hasCachedPassword || false} hasCachedPassword={mitmStatus.hasCachedPassword || false}
needsSudoPassword={mitmStatus.needsSudoPassword !== false}
apiKeys={apiKeys} apiKeys={apiKeys}
activeProviders={getActiveProviders()} activeProviders={getActiveProviders()}
hasActiveProviders={hasActiveProviders()} hasActiveProviders={hasActiveProviders()}

View File

@@ -9,6 +9,7 @@ import {
getCachedPassword, getCachedPassword,
setCachedPassword, setCachedPassword,
loadEncryptedPassword, loadEncryptedPassword,
isSudoPasswordRequired,
initDbHooks, initDbHooks,
} from "@/mitm/manager"; } from "@/mitm/manager";
import { getSettings, updateSettings } from "@/lib/localDb"; import { getSettings, updateSettings } from "@/lib/localDb";
@@ -40,6 +41,10 @@ function getPassword(provided) {
return provided || getCachedPassword() || null; return provided || getCachedPassword() || null;
} }
function requiresSudoPassword(pwd) {
return !isWin && !pwd && isSudoPasswordRequired();
}
function checkIsAdmin() { function checkIsAdmin() {
if (!isWin) return true; if (!isWin) return true;
try { try {
@@ -55,13 +60,16 @@ export async function GET() {
try { try {
const status = await getMitmStatus(); const status = await getMitmStatus();
const settings = await getSettings(); const settings = await getSettings();
const hasCachedPassword = !!getCachedPassword() || !!(await loadEncryptedPassword());
return NextResponse.json({ return NextResponse.json({
running: status.running, running: status.running,
pid: status.pid || null, pid: status.pid || null,
certExists: status.certExists || false, certExists: status.certExists || false,
certTrusted: status.certTrusted || false, certTrusted: status.certTrusted || false,
dnsStatus: status.dnsStatus || {}, dnsStatus: status.dnsStatus || {},
hasCachedPassword: !!getCachedPassword() || !!(await loadEncryptedPassword()), hasCachedPassword,
isWin,
needsSudoPassword: !isWin && !hasCachedPassword && isSudoPasswordRequired(),
isAdmin: checkIsAdmin(), isAdmin: checkIsAdmin(),
mitmRouterBaseUrl: mitmRouterBaseUrl:
(settings.mitmRouterBaseUrl && String(settings.mitmRouterBaseUrl).trim()) || (settings.mitmRouterBaseUrl && String(settings.mitmRouterBaseUrl).trim()) ||
@@ -79,9 +87,9 @@ export async function POST(request) {
const { apiKey, sudoPassword, mitmRouterBaseUrl } = await request.json(); const { apiKey, sudoPassword, mitmRouterBaseUrl } = await request.json();
const pwd = getPassword(sudoPassword) || await loadEncryptedPassword() || ""; const pwd = getPassword(sudoPassword) || await loadEncryptedPassword() || "";
if (!apiKey || (!isWin && !pwd)) { if (!apiKey || requiresSudoPassword(pwd)) {
return NextResponse.json( return NextResponse.json(
{ error: isWin ? "Missing apiKey" : "Missing apiKey or sudoPassword" }, { error: !apiKey ? "Missing apiKey" : "Missing sudoPassword" },
{ status: 400 } { status: 400 }
); );
} }
@@ -115,7 +123,7 @@ export async function DELETE(request) {
const { sudoPassword } = body; const { sudoPassword } = body;
const pwd = getPassword(sudoPassword) || await loadEncryptedPassword() || ""; const pwd = getPassword(sudoPassword) || await loadEncryptedPassword() || "";
if (!isWin && !pwd) { if (requiresSudoPassword(pwd)) {
return NextResponse.json({ error: "Missing sudoPassword" }, { status: 400 }); return NextResponse.json({ error: "Missing sudoPassword" }, { status: 400 });
} }
@@ -138,7 +146,7 @@ export async function PATCH(request) {
if (!tool || !action) { if (!tool || !action) {
return NextResponse.json({ error: "tool and action required" }, { status: 400 }); return NextResponse.json({ error: "tool and action required" }, { status: 400 });
} }
if (!isWin && !pwd) { if (requiresSudoPassword(pwd)) {
return NextResponse.json({ error: "Missing sudoPassword" }, { status: 400 }); return NextResponse.json({ error: "Missing sudoPassword" }, { status: 400 });
} }

View File

@@ -23,6 +23,20 @@ function isSudoAvailable() {
} }
} }
function canRunSudoWithoutPassword() {
if (IS_WIN || !isSudoAvailable()) return true;
try {
execSync("sudo -n true", { stdio: "ignore", windowsHide: true });
return true;
} catch {
return false;
}
}
function isSudoPasswordRequired() {
return !IS_WIN && isSudoAvailable() && !canRunSudoWithoutPassword();
}
/** /**
* Execute command with sudo password via stdin (macOS/Linux only). * Execute command with sudo password via stdin (macOS/Linux only).
* Without sudo in PATH (containers), runs via sh — same user, no elevation. * Without sudo in PATH (containers), runs via sh — same user, no elevation.
@@ -216,6 +230,8 @@ module.exports = {
removeAllDNSEntriesSync, removeAllDNSEntriesSync,
execWithPassword, execWithPassword,
isSudoAvailable, isSudoAvailable,
canRunSudoWithoutPassword,
isSudoPasswordRequired,
checkDNSEntry, checkDNSEntry,
checkAllDNSStatus, checkAllDNSStatus,
}; };

View File

@@ -5,7 +5,7 @@ const os = require("os");
const net = require("net"); const net = require("net");
const https = require("https"); const https = require("https");
const crypto = require("crypto"); const crypto = require("crypto");
const { addDNSEntry, removeDNSEntry, removeAllDNSEntries, checkAllDNSStatus, TOOL_HOSTS, isSudoAvailable } = require("./dns/dnsConfig"); const { addDNSEntry, removeDNSEntry, removeAllDNSEntries, checkAllDNSStatus, TOOL_HOSTS, isSudoAvailable, isSudoPasswordRequired } = require("./dns/dnsConfig");
const IS_WIN = process.platform === "win32"; const IS_WIN = process.platform === "win32";
const IS_MAC = process.platform === "darwin"; const IS_MAC = process.platform === "darwin";
@@ -139,9 +139,9 @@ function killProcess(pid, force = false, sudoPassword = null) {
} else { } else {
const sig = force ? "SIGKILL" : "SIGTERM"; const sig = force ? "SIGKILL" : "SIGTERM";
const cmd = `pkill -${sig} -P ${pid} 2>/dev/null; kill -${sig} ${pid} 2>/dev/null`; const cmd = `pkill -${sig} -P ${pid} 2>/dev/null; kill -${sig} ${pid} 2>/dev/null`;
if (sudoPassword) { if (sudoPassword || isSudoAvailable()) {
const { execWithPassword } = require("./dns/dnsConfig"); const { execWithPassword } = require("./dns/dnsConfig");
execWithPassword(cmd, sudoPassword).catch(() => exec(cmd, { windowsHide: true }, () => { })); execWithPassword(cmd, sudoPassword || "").catch(() => exec(cmd, { windowsHide: true }, () => { }));
} else { } else {
exec(cmd, { windowsHide: true }, () => { }); exec(cmd, { windowsHide: true }, () => { });
} }
@@ -279,9 +279,9 @@ async function killLeftoverMitm(sudoPassword) {
if (!IS_WIN && SERVER_PATH) { if (!IS_WIN && SERVER_PATH) {
try { try {
const escaped = SERVER_PATH.replace(/'/g, "'\\''"); const escaped = SERVER_PATH.replace(/'/g, "'\\''");
if (sudoPassword) { if (sudoPassword || isSudoAvailable()) {
const { execWithPassword } = require("./dns/dnsConfig"); const { execWithPassword } = require("./dns/dnsConfig");
await execWithPassword(`pkill -SIGKILL -f "${escaped}" 2>/dev/null || true`, sudoPassword).catch(() => { }); await execWithPassword(`pkill -SIGKILL -f "${escaped}" 2>/dev/null || true`, sudoPassword || "").catch(() => { });
} else { } else {
exec(`pkill -SIGKILL -f "${escaped}" 2>/dev/null || true`, { windowsHide: true }, () => { }); exec(`pkill -SIGKILL -f "${escaped}" 2>/dev/null || true`, { windowsHide: true }, () => { });
} }
@@ -465,7 +465,7 @@ async function startServer(apiKey, sudoPassword) {
if (linuxNoSystemTrust) { if (linuxNoSystemTrust) {
log(`🔐 Cert: skipping system trust (no sudo). Install ${rootCACertPath} as a trusted CA on machines that use this proxy.`); log(`🔐 Cert: skipping system trust (no sudo). Install ${rootCACertPath} as a trusted CA on machines that use this proxy.`);
} else { } else {
if (!password && !IS_WIN) { if (!password && isSudoPasswordRequired()) {
throw new Error("Sudo password required to install Root CA certificate"); throw new Error("Sudo password required to install Root CA certificate");
} }
try { try {
@@ -698,7 +698,7 @@ async function trustCert(sudoPassword) {
return; return;
} }
const password = sudoPassword || getCachedPassword() || await loadEncryptedPassword(); const password = sudoPassword || getCachedPassword() || await loadEncryptedPassword();
if (!password && !IS_WIN) throw new Error("Sudo password required to trust certificate"); if (!password && isSudoPasswordRequired()) throw new Error("Sudo password required to trust certificate");
await installCert(password, rootCACertPath); await installCert(password, rootCACertPath);
if (password) setCachedPassword(password); if (password) setCachedPassword(password);
} }
@@ -721,5 +721,6 @@ module.exports = {
setCachedPassword, setCachedPassword,
loadEncryptedPassword, loadEncryptedPassword,
clearEncryptedPassword, clearEncryptedPassword,
isSudoPasswordRequired,
initDbHooks, initDbHooks,
}; };