fix(api): return public-safe errors from the tunnel and MITM routes (#11872)

Closes a real Hard Rule #12 violation — 14 catch blocks across the tunnel/MITM routes echoed a raw err.message, which for Tailscale could leak a live tskey-* credential and always disclosed host layout / OS account name. Routes all 14 sites through a new toPublicSafeTunnelError() classifier, verified by a dedicated regression suite (14/14 passing) asserting no route echoes a raw error.message. Thanks for the security fix!
This commit is contained in:
Paco Cartones
2026-08-28 16:13:19 +02:00
committed by GitHub
parent 3f35f3afad
commit dff3f8b424
14 changed files with 477 additions and 46 deletions

View File

@@ -5,6 +5,7 @@ import path from "path";
import { z } from "zod";
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { toPublicSafeTunnelError } from "@/lib/api/publicSafeTunnelError";
import { resolveApiKey } from "@/shared/services/apiKeyResolver";
import { resolveMitmDataDir } from "@/mitm/dataDir";
import { KIRO_MITM_PROFILE } from "@/mitm/targets/kiro";
@@ -180,8 +181,10 @@ export async function GET(request: Request) {
return NextResponse.json(await buildMitmResponse());
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load MITM settings";
return NextResponse.json({ error: message }, { status: 500 });
return NextResponse.json(
toPublicSafeTunnelError(error, "Failed to load the MITM settings.", "settings/mitm GET"),
{ status: 500 }
);
}
}
@@ -236,8 +239,10 @@ export async function PUT(request: Request) {
return NextResponse.json(await buildMitmResponse());
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to update MITM settings";
return NextResponse.json({ error: message }, { status: 500 });
return NextResponse.json(
toPublicSafeTunnelError(error, "Failed to update the MITM settings.", "settings/mitm PUT"),
{ status: 500 }
);
}
}
@@ -274,8 +279,13 @@ export async function POST(request: Request) {
return NextResponse.json(await buildMitmResponse());
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to regenerate MITM certificate";
return NextResponse.json({ error: message }, { status: 500 });
return NextResponse.json(
toPublicSafeTunnelError(
error,
"Failed to regenerate the MITM certificate.",
"settings/mitm POST"
),
{ status: 500 }
);
}
}

View File

@@ -1,12 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import {
formatValidationMessage,
isValidationFailure,
validateBody,
} from "@/shared/validation/helpers";
import {
getCloudflaredTunnelStatus,
startCloudflaredTunnel,
stopCloudflaredTunnel,
} from "@/lib/cloudflaredTunnel";
import { toPublicSafeTunnelError } from "@/lib/api/publicSafeTunnelError";
export const dynamic = "force-dynamic";
@@ -28,9 +33,11 @@ export async function GET(request: NextRequest) {
return NextResponse.json(status);
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Failed to load cloudflared tunnel status",
},
toPublicSafeTunnelError(
error,
"Failed to load the cloudflared tunnel status.",
"tunnels/cloudflared GET"
),
{ status: 500 }
);
}
@@ -50,7 +57,10 @@ export async function POST(request: NextRequest) {
const validation = validateBody(actionSchema, rawBody);
if (isValidationFailure(validation)) {
return validation.response;
// validateBody() returns { success, error } — it has no `response` field, so
// the previous `return validation.response` returned undefined and Next
// answered with a framework 500 instead of this 400.
return NextResponse.json({ error: formatValidationMessage(validation.error) }, { status: 400 });
}
const parsed = validation.data;
@@ -66,9 +76,11 @@ export async function POST(request: NextRequest) {
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Failed to update cloudflared tunnel",
},
toPublicSafeTunnelError(
error,
"Failed to update the cloudflared tunnel.",
"tunnels/cloudflared POST"
),
{ status: 500 }
);
}

View File

@@ -1,8 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import {
formatValidationMessage,
isValidationFailure,
validateBody,
} from "@/shared/validation/helpers";
import { getNgrokTunnelStatus, startNgrokTunnel, stopNgrokTunnel } from "@/lib/ngrokTunnel";
import { toPublicSafeTunnelError } from "@/lib/api/publicSafeTunnelError";
export const dynamic = "force-dynamic";
@@ -25,9 +30,11 @@ export async function GET(request: NextRequest) {
return NextResponse.json(status);
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Failed to load ngrok tunnel status",
},
toPublicSafeTunnelError(
error,
"Failed to load the ngrok tunnel status.",
"tunnels/ngrok GET"
),
{ status: 500 }
);
}
@@ -47,7 +54,10 @@ export async function POST(request: NextRequest) {
const validation = validateBody(actionSchema, rawBody);
if (isValidationFailure(validation)) {
return validation.response;
// validateBody() returns { success, error } — it has no `response` field, so
// the previous `return validation.response` returned undefined and Next
// answered with a framework 500 instead of this 400.
return NextResponse.json({ error: formatValidationMessage(validation.error) }, { status: 400 });
}
const parsed = validation.data;
@@ -65,9 +75,7 @@ export async function POST(request: NextRequest) {
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Failed to update ngrok tunnel",
},
toPublicSafeTunnelError(error, "Failed to update the ngrok tunnel.", "tunnels/ngrok POST"),
{ status: 500 }
);
}

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { getTailscaleCheckStatus } from "@/lib/tailscaleTunnel";
import { toPublicSafeTunnelError } from "@/lib/api/publicSafeTunnelError";
import { requireTailscaleAuth } from "../routeUtils";
export const dynamic = "force-dynamic";
@@ -13,9 +14,11 @@ export async function GET(request: Request) {
return NextResponse.json(status);
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Failed to check Tailscale state",
},
toPublicSafeTunnelError(
error,
"Failed to check the Tailscale state.",
"tunnels/tailscale/check GET"
),
{ status: 500 }
);
}

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { disableTailscaleTunnel } from "@/lib/tailscaleTunnel";
import { toPublicSafeTunnelError } from "@/lib/api/publicSafeTunnelError";
import { parseOptionalJsonBody, requireTailscaleAuth, tailscaleSudoSchema } from "../routeUtils";
export const dynamic = "force-dynamic";
@@ -16,9 +17,11 @@ export async function POST(request: Request) {
return NextResponse.json(result);
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Failed to disable Tailscale Funnel",
},
toPublicSafeTunnelError(
error,
"Failed to disable the Tailscale Funnel.",
"tunnels/tailscale/disable POST"
),
{ status: 500 }
);
}

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { enableTailscaleTunnel } from "@/lib/tailscaleTunnel";
import { toPublicSafeTunnelError } from "@/lib/api/publicSafeTunnelError";
import { parseOptionalJsonBody, requireTailscaleAuth, tailscaleEnableSchema } from "../routeUtils";
export const dynamic = "force-dynamic";
@@ -16,9 +17,11 @@ export async function POST(request: Request) {
return NextResponse.json(result);
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Failed to enable Tailscale Funnel",
},
toPublicSafeTunnelError(
error,
"Failed to enable the Tailscale Funnel.",
"tunnels/tailscale/enable POST"
),
{ status: 500 }
);
}

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { getTailscaleTunnelStatus, installTailscale } from "@/lib/tailscaleTunnel";
import { toPublicSafeTunnelError } from "@/lib/api/publicSafeTunnelError";
import { parseOptionalJsonBody, requireTailscaleAuth, tailscaleSudoSchema } from "../routeUtils";
export const dynamic = "force-dynamic";
@@ -31,9 +32,14 @@ export async function POST(request: Request) {
status: await getTailscaleTunnelStatus(),
});
} catch (error) {
pushEvent("error", {
error: error instanceof Error ? error.message : "Failed to install Tailscale",
});
pushEvent(
"error",
toPublicSafeTunnelError(
error,
"Failed to install Tailscale.",
"tunnels/tailscale/install POST"
)
);
} finally {
controller.close();
}

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { startTailscaleLogin } from "@/lib/tailscaleTunnel";
import { toPublicSafeTunnelError } from "@/lib/api/publicSafeTunnelError";
import { parseOptionalJsonBody, requireTailscaleAuth, tailscaleLoginSchema } from "../routeUtils";
export const dynamic = "force-dynamic";
@@ -16,9 +17,11 @@ export async function POST(request: Request) {
return NextResponse.json(result);
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Failed to start Tailscale login",
},
toPublicSafeTunnelError(
error,
"Failed to start the Tailscale login.",
"tunnels/tailscale/login POST"
),
{ status: 500 }
);
}

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { getTailscaleTunnelStatus } from "@/lib/tailscaleTunnel";
import { toPublicSafeTunnelError } from "@/lib/api/publicSafeTunnelError";
import { requireTailscaleAuth } from "./routeUtils";
export const dynamic = "force-dynamic";
@@ -13,9 +14,11 @@ export async function GET(request: Request) {
return NextResponse.json(status);
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Failed to load Tailscale status",
},
toPublicSafeTunnelError(
error,
"Failed to load the Tailscale status.",
"tunnels/tailscale GET"
),
{ status: 500 }
);
}

View File

@@ -1,7 +1,11 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import {
formatValidationMessage,
isValidationFailure,
validateBody,
} from "@/shared/validation/helpers";
export const tailscaleEnableSchema = z.object({
sudoPassword: z.string().optional(),
@@ -34,7 +38,15 @@ export async function parseOptionalJsonBody<T extends z.ZodTypeAny>(request: Req
const validation = validateBody(schema, rawBody);
if (isValidationFailure(validation)) {
return { response: validation.response };
// validateBody() returns { success, error } — it has no `response` field, so
// the previous `{ response: validation.response }` handed every caller an
// `undefined` response and Next answered with a framework 500 instead of a 400.
return {
response: NextResponse.json(
{ error: formatValidationMessage(validation.error) },
{ status: 400 }
),
};
}
return { data: validation.data };

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { getTailscaleTunnelStatus, startTailscaleDaemon } from "@/lib/tailscaleTunnel";
import { toPublicSafeTunnelError } from "@/lib/api/publicSafeTunnelError";
import { parseOptionalJsonBody, requireTailscaleAuth, tailscaleSudoSchema } from "../routeUtils";
export const dynamic = "force-dynamic";
@@ -19,9 +20,11 @@ export async function POST(request: Request) {
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Failed to start the Tailscale daemon",
},
toPublicSafeTunnelError(
error,
"Failed to start the Tailscale daemon.",
"tunnels/tailscale/start-daemon POST"
),
{ status: 500 }
);
}

View File

@@ -0,0 +1,96 @@
/**
* Public-safe error bodies for the tunnel and MITM management routes.
*
* Hard Rule #12 forbids returning a raw `err.message` in an HTTP body, and
* `sanitizeErrorMessage()` is the repo's general answer. It is not enough here:
* it only rewrites tokens that look like an absolute path ending in a *source*
* extension (`ts|tsx|js|jsx|mjs|cjs` — see `SOURCE_EXT` in
* open-sse/utils/error.ts), so the three leak shapes these routes actually
* produce all survive it verbatim:
*
* - config/state paths: `ENOENT ... open '/home/<user>/.omniroute/data/tunnels.json'`
* - binary paths: `spawn /usr/local/bin/cloudflared ENOENT`
* - Tailscale auth keys: `invalid key tskey-auth-kMn3Qz7RtY-9fVbXsPq2LdWc`
*
* These come from child processes (`cloudflared`, `tailscale`, `tailscaled`,
* `ngrok`) and from filesystem I/O on the operator's home directory, so the raw
* message discloses the host layout, the install location, the OS account name
* and — for Tailscale — a live credential.
*
* Reachability is not uniform. `/api/tunnels/ngrok` (both methods),
* `/api/tunnels/tailscale` and `/api/tunnels/tailscale/check` are NOT in
* `LOCAL_ONLY_API_PREFIXES`, and `GET /api/tunnels/cloudflared` is explicitly
* exempted through `LOCAL_ONLY_API_GET_EXEMPTIONS` (#11531), so those bodies can
* reach a non-loopback caller. The remaining tunnel routes and
* `/api/settings/mitm` are loopback-gated; they are covered here for one
* consistent contract, not because they are remotely reachable.
*
* The contract deliberately does NOT try to scrub the upstream text. It returns
* a fixed operator-facing sentence plus a coarse machine-readable `reason`, and
* logs the real error server-side where the operator can still read it.
*/
/** Coarse classification a client can branch on without seeing host details. */
export type PublicSafeTunnelErrorReason =
"not_installed" | "permission_denied" | "already_running" | "timeout" | "network" | "unknown";
export interface PublicSafeTunnelErrorBody {
error: string;
reason: PublicSafeTunnelErrorReason;
}
/** Classify without echoing: only the reason label ever reaches the client. */
export function classifyTunnelError(error: unknown): PublicSafeTunnelErrorReason {
const raw = (error instanceof Error ? error.message : String(error ?? "")).toLowerCase();
if (!raw) return "unknown";
if (raw.includes("enoent") || raw.includes("not found") || raw.includes("not installed")) {
return "not_installed";
}
if (
raw.includes("eacces") ||
raw.includes("eperm") ||
raw.includes("permission denied") ||
raw.includes("sudo") ||
raw.includes("must be run as root")
) {
return "permission_denied";
}
if (
raw.includes("eaddrinuse") ||
raw.includes("already running") ||
raw.includes("already in use")
) {
return "already_running";
}
if (raw.includes("etimedout") || raw.includes("timed out") || raw.includes("timeout")) {
return "timeout";
}
if (
raw.includes("econnrefused") ||
raw.includes("econnreset") ||
raw.includes("enotfound") ||
raw.includes("network")
) {
return "network";
}
return "unknown";
}
/**
* Build the 500 body for a tunnel/MITM route.
*
* @param error The caught value. Never echoed.
* @param fallback Operator-facing sentence describing what failed. Must be a
* literal owned by the route — never derived from `error`.
* @param context Short route label used only for the server-side log line.
*/
export function toPublicSafeTunnelError(
error: unknown,
fallback: string,
context: string
): PublicSafeTunnelErrorBody {
// The operator still needs the real cause; it belongs in the server log, not
// in a body that may cross a tunnel.
console.error(`[${context}]`, error);
return { error: fallback, reason: classifyTunnelError(error) };
}