fix(security): bind the MITM proxy to loopback and stop /api/providers/client from returning credentials (GHSA-qxg2-rm3h-4cxp) (#15886)

- src/mitm/server.cjs listened on every interface, so a LAN peer could drive
  intercept() with the operator's ROUTER_API_KEY or use passthrough() as an open
  TLS relay. It now listens on 127.0.0.1 only, like the inspector proxy.
- GET /api/providers/client spread every connection row verbatim, including the
  decrypted apiKey and OAuth tokens, and is reachable anonymously when
  requireLogin is off. No dashboard consumer uses those fields; they are now
  masked/removed the same way GET /api/providers does.

Co-authored-by: CodeSavvy77 <CodeSavvy77@users.noreply.github.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza 2026-10-09 12:22:13 -03:00 • committed by GitHub
parent 7ab17b666e
commit 35ef635c4f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 82 additions and 5 deletions

View file

@ -1,15 +1,25 @@
import { NextResponse } from "next/server";
import { getProviderConnections } from "@/lib/db/providers";
import { maskStoredApiKey } from "@/lib/apiKeyExposure";
import { sanitizeProviderSpecificDataForResponse } from "@/lib/providers/requestDefaults";
// GET /api/providers/client - List all connections for client (includes sensitive fields for sync)
// GET /api/providers/client - List connections for the dashboard widgets.
// No consumer needs credentials, and this route is reachable anonymously when
// requireLogin is off, so secrets are masked exactly like GET /api/providers
// (GHSA-qxg2-rm3h-4cxp).
export async function GET() {
try {
const connections = await getProviderConnections();
// Include sensitive fields for sync to cloud (only accessible from same origin)
const clientConnections = connections.map((c) => ({
...c,
// Don't hide sensitive fields here since this is for internal sync
apiKey: c.apiKey ? maskStoredApiKey(c.apiKey) : undefined,
accessToken: undefined,
refreshToken: undefined,
idToken: undefined,
providerSpecificData: c.providerSpecificData
? sanitizeProviderSpecificDataForResponse(c.providerSpecificData)
: undefined,
}));
return NextResponse.json({ connections: clientConnections });

View file

@ -29,6 +29,12 @@ const TARGET_HOSTS = new Set([
const TARGET_HOST_AGENT = new Map();
for (const h of TARGET_HOSTS) TARGET_HOST_AGENT.set(h, "antigravity");
// The MITM only intercepts this host's own traffic (/etc/hosts points the target
// domains at 127.0.0.1), so it must never listen on other interfaces: a LAN peer
// could otherwise drive intercept() with the operator's ROUTER_API_KEY or use
// passthrough() as an open TLS relay (GHSA-qxg2-rm3h-4cxp).
const MITM_LISTEN_HOST = "127.0.0.1";
const parsedLocalPort = Number.parseInt(process.env.MITM_LOCAL_PORT || "443", 10);
const LOCAL_PORT =
Number.isInteger(parsedLocalPort) && parsedLocalPort > 0 && parsedLocalPort <= 65535
@ -898,10 +904,10 @@ async function startMitmServer() {
server.headersTimeout = MITM_IDLE_TIMEOUT_MS; // time allowed to send headers
server.keepAliveTimeout = MITM_IDLE_TIMEOUT_MS; // idle keep-alive window
server.listen(LOCAL_PORT, () => {
server.listen(LOCAL_PORT, MITM_LISTEN_HOST, () => {
stats.startedAt = new Date().toISOString();
writeStats();
console.log(`🚀 MITM ready on :${LOCAL_PORT} → ${ROUTER_URL}`);
console.log(`🚀 MITM ready on ${MITM_LISTEN_HOST}:${LOCAL_PORT} → ${ROUTER_URL}`);
});
server.on("connection", (socket) => {

View file

@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-providers-client-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = "providers-client-test-secret";
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
const core = await import("../../src/lib/db/core.ts");
const providers = await import("../../src/lib/db/providers.ts");
const clientRoute = await import("../../src/app/api/providers/client/route.ts");
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("GET /api/providers/client never returns stored credentials (GHSA-qxg2-rm3h-4cxp)", async () => {
const apiKey = "sk-client-route-secret-0123456789abcdef";
await providers.createProviderConnection({
provider: "openai",
authType: "apikey",
name: "client-route-secret",
apiKey,
accessToken: "access-token-secret-value",
refreshToken: "refresh-token-secret-value",
idToken: "id-token-secret-value",
});
const res = await clientRoute.GET();
assert.equal(res.status, 200);
const text = await res.text();
for (const secret of [
apiKey,
"access-token-secret-value",
"refresh-token-secret-value",
"id-token-secret-value",
]) {
assert.ok(!text.includes(secret), `response leaked ${secret.slice(0, 12)}…`);
}
const { connections } = JSON.parse(text);
const conn = connections.find((c: { name: string }) => c.name === "client-route-secret");
assert.ok(conn, "the connection metadata is still listed for the widgets");
assert.equal(conn.provider, "openai");
});
test("the MITM listener binds to loopback only (GHSA-qxg2-rm3h-4cxp)", () => {
const source = fs.readFileSync(
path.join(import.meta.dirname, "../../src/mitm/server.cjs"),
"utf8"
);
assert.match(source, /const MITM_LISTEN_HOST = "127\.0\.0\.1";/);
const listens = [...source.matchAll(/server\.listen\(([^)]*)\)?/g)].map((m) => m[1]);
assert.ok(listens.length > 0, "server.listen call present");
for (const args of listens) {
assert.match(args, /^LOCAL_PORT,\s*MITM_LISTEN_HOST\b/, `listen without a host: ${args}`);
}
});