diff --git a/src/app/(dashboard)/dashboard/settings/components/MitmProxyTab.tsx b/src/app/(dashboard)/dashboard/settings/components/MitmProxyTab.tsx index 0ee4cd7c26..29f2d44c24 100644 --- a/src/app/(dashboard)/dashboard/settings/components/MitmProxyTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/MitmProxyTab.tsx @@ -4,6 +4,8 @@ import { useCallback, useEffect, useState } from "react"; import { useTranslations } from "next-intl"; import { Card } from "@/shared/components"; +const TRANSPARENT_MITM_PORT = 443; + type MitmTargetRoute = { id: string; name: string; @@ -80,7 +82,7 @@ export default function MitmProxyTab() { const data = await response.json().catch(() => ({})); if (!response.ok) throw new Error(data.error || t("loadFailed")); setStatus(data); - setPort(String(data.port || 443)); + setPort(String(TRANSPARENT_MITM_PORT)); setFeedback(null); } catch (error) { setFeedback({ @@ -108,7 +110,7 @@ export default function MitmProxyTab() { const data = await response.json().catch(() => ({})); if (!response.ok) throw new Error(data.error || t("saveFailed")); setStatus(data); - setPort(String(data.port || 443)); + setPort(String(TRANSPARENT_MITM_PORT)); setFeedback({ type: "success", message: successMessage }); } catch (error) { setFeedback({ @@ -122,18 +124,19 @@ export default function MitmProxyTab() { const savePort = () => { const parsedPort = Number.parseInt(port, 10); - if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) { + if (parsedPort !== TRANSPARENT_MITM_PORT) { setFeedback({ type: "error", message: t("invalidPort") }); + setPort(String(TRANSPARENT_MITM_PORT)); return; } - void updateMitm({ port: parsedPort }, t("settingsSaved")); + void updateMitm({ port: TRANSPARENT_MITM_PORT }, t("settingsSaved")); }; const toggleMitm = () => { void updateMitm( { enabled: !status.running, - port: Number.parseInt(port, 10) || 443, + port: TRANSPARENT_MITM_PORT, apiKey: String(apiKey || "").trim() || undefined, sudoPassword: sudoPassword || undefined, }, @@ -240,7 +243,7 @@ export default function MitmProxyTab() { setPort(event.target.value)} + readOnly disabled={status.running} className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40 disabled:opacity-60" /> diff --git a/src/app/api/settings/mitm/route.ts b/src/app/api/settings/mitm/route.ts index 4509291a9f..247742ff69 100644 --- a/src/app/api/settings/mitm/route.ts +++ b/src/app/api/settings/mitm/route.ts @@ -34,6 +34,8 @@ type MitmConfig = { }; const DEFAULT_PORT = 443; +const MITM_PORT_ERROR = + "Transparent MITM interception currently requires port 443 because DNS override does not redirect destination ports."; const updateMitmSchema = z.object({ enabled: z.boolean().optional(), @@ -92,17 +94,10 @@ function defaultTargets(port = DEFAULT_PORT): MitmTargetRoute[] { function readConfig(): MitmConfig { try { - const raw = JSON.parse(fs.readFileSync(getConfigPath(), "utf8")); - const port = - typeof raw.port === "number" && - Number.isInteger(raw.port) && - raw.port > 0 && - raw.port <= 65535 - ? raw.port - : DEFAULT_PORT; + JSON.parse(fs.readFileSync(getConfigPath(), "utf8")); return { - port, - targets: defaultTargets(port), + port: DEFAULT_PORT, + targets: defaultTargets(DEFAULT_PORT), }; } catch { return { @@ -112,10 +107,10 @@ function readConfig(): MitmConfig { } } -function writeConfig(config: MitmConfig) { +function writeConfig() { const mitmDir = getMitmDir(); fs.mkdirSync(mitmDir, { recursive: true }); - fs.writeFileSync(getConfigPath(), JSON.stringify({ port: config.port }, null, 2)); + fs.writeFileSync(getConfigPath(), JSON.stringify({ port: DEFAULT_PORT }, null, 2)); } function readStats(): MitmStats { @@ -197,10 +192,14 @@ export async function PUT(request: Request) { } const config = readConfig(); - if (parsed.data.port) { - config.port = parsed.data.port; + if (parsed.data.port !== undefined && parsed.data.port !== DEFAULT_PORT) { + return NextResponse.json({ error: MITM_PORT_ERROR }, { status: 400 }); + } + + if (parsed.data.port !== undefined) { + config.port = DEFAULT_PORT; config.targets = defaultTargets(config.port); - writeConfig(config); + writeConfig(); } if (typeof parsed.data.enabled === "boolean") { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 7c5c207a08..24cebefcfd 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2445,7 +2445,7 @@ "stoppedSuccess": "MITM proxy stopped.", "saveFailed": "Failed to update MITM settings.", "loadFailed": "Failed to load MITM settings.", - "invalidPort": "Port must be between 1 and 65535.", + "invalidPort": "Transparent MITM interception currently requires port 443.", "certificate": "CA Certificate", "certificateReady": "Certificate is available for client trust installation.", "certificateMissing": "Certificate has not been generated yet.", diff --git a/tests/integration/api-routes-critical.test.ts b/tests/integration/api-routes-critical.test.ts index 4ab8291f7a..cebdfc3253 100644 --- a/tests/integration/api-routes-critical.test.ts +++ b/tests/integration/api-routes-critical.test.ts @@ -14,6 +14,7 @@ const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); const localDb = await import("../../src/lib/localDb.ts"); const proxiesRoute = await import("../../src/app/api/v1/management/proxies/route.ts"); const settingsProxyRoute = await import("../../src/app/api/settings/proxy/route.ts"); +const settingsMitmRoute = await import("../../src/app/api/settings/mitm/route.ts"); const v1ModelsRoute = await import("../../src/app/api/v1/models/route.ts"); const MACHINE_ID = "1234567890abcdef"; @@ -422,6 +423,50 @@ test("critical routes: settings proxy prefers registry assignment for global loo assert.equal(body.proxy.username, "global-user"); }); +test("critical routes: MITM settings reject non-443 transparent interception ports", async () => { + await enableManagementAuth(); + + const mitmDir = path.join(TEST_DATA_DIR, "mitm"); + fs.mkdirSync(mitmDir, { recursive: true }); + fs.writeFileSync(path.join(mitmDir, "settings.json"), JSON.stringify({ port: 9443 })); + + const staleConfig = await settingsMitmRoute.GET( + await makeManagementSessionRequest("http://localhost/api/settings/mitm") + ); + const staleConfigBody = (await staleConfig.json()) as any; + + const invalidPort = await settingsMitmRoute.PUT( + await makeManagementSessionRequest("http://localhost/api/settings/mitm", { + method: "PUT", + body: { port: 9443 }, + }) + ); + const invalidPortBody = (await invalidPort.json()) as any; + + const validPort = await settingsMitmRoute.PUT( + await makeManagementSessionRequest("http://localhost/api/settings/mitm", { + method: "PUT", + body: { port: 443 }, + }) + ); + const validPortBody = (await validPort.json()) as any; + const staleAntigravityTarget = staleConfigBody.targets.find( + (target: any) => target.id === "antigravity" + ); + const validAntigravityTarget = validPortBody.targets.find( + (target: any) => target.id === "antigravity" + ); + + assert.equal(staleConfig.status, 200); + assert.equal(staleConfigBody.port, 443); + assert.equal(staleAntigravityTarget?.localPort, 443); + assert.equal(invalidPort.status, 400); + assert.match(invalidPortBody.error, /requires port 443/i); + assert.equal(validPort.status, 200); + assert.equal(validPortBody.port, 443); + assert.equal(validAntigravityTarget?.localPort, 443); +}); + test("critical routes: settings proxy covers global fallback and socks5 gating", async () => { const setLegacyGlobalProxy = await settingsProxyRoute.PUT( makeRequest("http://localhost/api/settings/proxy", {