mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-10-21 13:51:56 +00:00
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:
parent
7ab17b666e
commit
35ef635c4f
3 changed files with 82 additions and 5 deletions
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
61
tests/unit/providers-client-route-secrets-qxg2.test.ts
Normal file
61
tests/unit/providers-client-route-secrets-qxg2.test.ts
Normal 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}`);
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue