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
const isWindows = typeof navigator !== "undefined" && navigator.userAgent?.includes("Windows");
// MITM elevation is decided by the server OS, not by this browser's OS.
const serverIsWindows = status?.isWin === true;
const canRunWithoutPassword = serverIsWindows || status?.hasCachedPassword || status?.needsSudoPassword === false;
const handleStart = () => {
if (isWindows || status?.hasCachedPassword) {
if (canRunWithoutPassword) {
doStart("");
} else {
setShowPasswordModal(true);
@@ -101,7 +102,7 @@ export default function AntigravityToolCard({
};
const handleStop = () => {
if (isWindows || status?.hasCachedPassword) {
if (canRunWithoutPassword) {
doStop("");
} else {
setShowPasswordModal(true);
@@ -385,7 +386,7 @@ export default function AntigravityToolCard({
)}
{/* 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">
<span className="material-symbols-outlined text-[14px]">warning</span>
<span>Windows: Run terminal (9Router) as Administrator to enable MITM</span>

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import { Card, Button, Badge, Input } from "@/shared/components";
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 [showPasswordModal, setShowPasswordModal] = useState(false);
const [sudoPassword, setSudoPassword] = useState("");
const [selectedApiKey, setSelectedApiKey] = useState("");
const [selectedApiKey, setSelectedApiKey] = useState(() => apiKeys?.[0]?.key || "");
const [pendingAction, setPendingAction] = useState(null);
const [modalError, setModalError] = useState(null);
const [actionError, setActionError] = useState(null);
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;
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) {
setSelectedApiKey(apiKeys[0].key);
}
}, [apiKeys, selectedApiKey]);
useEffect(() => {
fetchStatus();
}, []);
const fetchStatus = async () => {
const fetchStatus = useCallback(async () => {
try {
const res = await fetch("/api/cli-tools/antigravity-mitm");
if (res.ok) {
@@ -47,11 +38,17 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
} catch {
setStatus({ running: false, certExists: false, dnsStatus: {} });
}
};
}, [onStatusChange]);
useEffect(() => {
queueMicrotask(() => {
fetchStatus();
});
}, [fetchStatus]);
const handleAction = (action) => {
setActionError(null);
if (isWindows || status?.hasCachedPassword) {
if (canRunWithoutPassword) {
doAction(action, "");
} else {
setPendingAction(action);
@@ -219,7 +216,7 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
) : (
<button
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"
>
<span className="material-symbols-outlined text-[16px]">play_circle</span>
@@ -240,7 +237,7 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
)}
{/* 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">
<span className="material-symbols-outlined text-[14px]">shield_lock</span>
<span>Administrator required restart 9Router as Administrator to use MITM</span>

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import {
getCachedPassword,
setCachedPassword,
loadEncryptedPassword,
isSudoPasswordRequired,
initDbHooks,
} from "@/mitm/manager";
import { getSettings, updateSettings } from "@/lib/localDb";
@@ -40,6 +41,10 @@ function getPassword(provided) {
return provided || getCachedPassword() || null;
}
function requiresSudoPassword(pwd) {
return !isWin && !pwd && isSudoPasswordRequired();
}
function checkIsAdmin() {
if (!isWin) return true;
try {
@@ -55,13 +60,16 @@ export async function GET() {
try {
const status = await getMitmStatus();
const settings = await getSettings();
const hasCachedPassword = !!getCachedPassword() || !!(await loadEncryptedPassword());
return NextResponse.json({
running: status.running,
pid: status.pid || null,
certExists: status.certExists || false,
certTrusted: status.certTrusted || false,
dnsStatus: status.dnsStatus || {},
hasCachedPassword: !!getCachedPassword() || !!(await loadEncryptedPassword()),
hasCachedPassword,
isWin,
needsSudoPassword: !isWin && !hasCachedPassword && isSudoPasswordRequired(),
isAdmin: checkIsAdmin(),
mitmRouterBaseUrl:
(settings.mitmRouterBaseUrl && String(settings.mitmRouterBaseUrl).trim()) ||
@@ -79,9 +87,9 @@ export async function POST(request) {
const { apiKey, sudoPassword, mitmRouterBaseUrl } = await request.json();
const pwd = getPassword(sudoPassword) || await loadEncryptedPassword() || "";
if (!apiKey || (!isWin && !pwd)) {
if (!apiKey || requiresSudoPassword(pwd)) {
return NextResponse.json(
{ error: isWin ? "Missing apiKey" : "Missing apiKey or sudoPassword" },
{ error: !apiKey ? "Missing apiKey" : "Missing sudoPassword" },
{ status: 400 }
);
}
@@ -115,7 +123,7 @@ export async function DELETE(request) {
const { sudoPassword } = body;
const pwd = getPassword(sudoPassword) || await loadEncryptedPassword() || "";
if (!isWin && !pwd) {
if (requiresSudoPassword(pwd)) {
return NextResponse.json({ error: "Missing sudoPassword" }, { status: 400 });
}
@@ -138,7 +146,7 @@ export async function PATCH(request) {
if (!tool || !action) {
return NextResponse.json({ error: "tool and action required" }, { status: 400 });
}
if (!isWin && !pwd) {
if (requiresSudoPassword(pwd)) {
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).
* Without sudo in PATH (containers), runs via sh — same user, no elevation.
@@ -216,6 +230,8 @@ module.exports = {
removeAllDNSEntriesSync,
execWithPassword,
isSudoAvailable,
canRunSudoWithoutPassword,
isSudoPasswordRequired,
checkDNSEntry,
checkAllDNSStatus,
};

View File

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