fix(mcp): preserve caller identity for internal REST hops (#9260)

Validated in local merge-train (devbox-vm-06-dev002) @ combined-tip (FAST gates green: static + changed tests + vitest — only pre-existing audit.test.ts flake). Evidence: /home/diegosouzapw/dev/proxys/OmniRoute/.claude/worktrees/merge-train-20260805-213228-suite.log
This commit is contained in:
ffichman
2026-08-05 21:45:27 -03:00
committed by GitHub
parent bc876740f5
commit c10dace3b3
7 changed files with 165 additions and 0 deletions

View File

@@ -46,6 +46,7 @@ import {
type McpToolExtraLike,
} from "./scopeEnforcement.ts";
import { getMcpHttpAuthHeadersForInternalFetch } from "./httpAuthContext.ts";
import { getInternalServiceAuthHeaders } from "../../src/lib/api/internalServiceAuth.ts";
import {
handleSimulateRoute,
handleSetBudgetGuard,
@@ -203,6 +204,9 @@ export async function omniRouteFetch(path: string, options: RequestInit = {}): P
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
...getMcpHttpAuthHeadersForInternalFetch(),
...((options.headers as Record<string, string>) || {}),
// Authenticate only the server-to-server hop. This does not replace or
// weaken the caller identity forwarded above.
...getInternalServiceAuthHeaders(),
};
const signal = options.signal || AbortSignal.timeout(10000);

View File

@@ -0,0 +1,39 @@
import { readFileSync } from "node:fs";
import { timingSafeEqual } from "node:crypto";
import { AUTHZ_HEADER_PEER_LOCALITY } from "@/server/authz/headers";
export const INTERNAL_SERVICE_AUTH_HEADER = "x-omniroute-internal-service-token";
function configuredToken(): string {
const inlineToken = process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN?.trim();
if (inlineToken) return inlineToken;
const tokenFile = process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE?.trim();
if (!tokenFile) return "";
try {
return readFileSync(tokenFile, "utf8").trim();
} catch {
return "";
}
}
export function getInternalServiceAuthHeaders(): Record<string, string> {
const token = configuredToken();
return token ? { [INTERNAL_SERVICE_AUTH_HEADER]: token } : {};
}
export function isInternalServiceRequest(request: Request): boolean {
const expected = configuredToken();
const provided = request.headers.get(INTERNAL_SERVICE_AUTH_HEADER)?.trim() || "";
if (!expected || !provided || expected.length !== provided.length) return false;
return timingSafeEqual(Buffer.from(provided, "utf8"), Buffer.from(expected, "utf8"));
}
export function isTrustedLoopbackInternalServiceRequest(request: Request): boolean {
return (
request.headers.get(AUTHZ_HEADER_PEER_LOCALITY) === "loopback" &&
isInternalServiceRequest(request)
);
}

View File

@@ -4,6 +4,7 @@ import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
import { getApiKeyMetadata } from "@/lib/db/apiKeys";
import { isCliTokenAuthValid } from "@/lib/middleware/cliTokenAuth";
import { evaluateAccessTokenAuth } from "@/server/authz/accessTokenAuth";
import { isTrustedLoopbackInternalServiceRequest } from "@/lib/api/internalServiceAuth";
import {
MANAGE_SCOPE,
hasManageScope as hasManageScopeShared,
@@ -47,6 +48,10 @@ export async function requireManagementAuth(
return null;
}
if (isTrustedLoopbackInternalServiceRequest(request)) {
return null;
}
// CLI machine-id token allows localhost CLI access without an explicit API key.
if (await isCliTokenAuthValid(request)) {
return null;

View File

@@ -9,6 +9,7 @@ import { getApiKeyMetadata } from "../../../lib/db/apiKeys";
import { hasManageScope } from "../../../lib/api/requireManagementAuth";
import { hasMcpConnectOrManageScope, MCP_CONNECT_SCOPE } from "../../../shared/constants/managementScopes";
import { evaluateAccessTokenAuth } from "../accessTokenAuth";
import { isInternalServiceRequest } from "../../../lib/api/internalServiceAuth";
import { CLI_TOKEN_HEADER, PEER_IP_HEADER, VIA_PROXY_HEADER } from "../headers";
import { resolveStampedPeer, resolveStampedViaProxy } from "../peerStamp";
import {
@@ -233,6 +234,14 @@ export const managementPolicy: RoutePolicy = {
return allow({ kind: "management_key", id: "model-sync", label: "internal-model-sync" });
}
if (isLoopbackRequest(ctx) && isInternalServiceRequest(ctx.request as unknown as Request)) {
return allow({
kind: "management_key",
id: "internal-service",
label: "internal-service-token",
});
}
if (hasValidCliToken(ctx)) {
return allow({ kind: "management_key", id: "cli", label: "local-cli-token" });
}

View File

@@ -16,6 +16,7 @@ const core = await import("../../../src/lib/db/core.ts");
const apiKeysDb = await import("../../../src/lib/db/apiKeys.ts");
const settingsDb = await import("../../../src/lib/db/settings.ts");
const modelSync = await import("../../../src/shared/services/modelSyncScheduler.ts");
const internalServiceAuth = await import("../../../src/lib/api/internalServiceAuth.ts");
const ORIGINAL_JWT = process.env.JWT_SECRET;
const ORIGINAL_INITIAL = process.env.INITIAL_PASSWORD;
@@ -146,6 +147,26 @@ test("managementPolicy: rejects 401 when auth required and no credentials", asyn
}
});
test("managementPolicy: allows a valid internal service token only from loopback", async () => {
process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy";
process.env.INITIAL_PASSWORD = "initial-pass";
process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN = "internal-service-token-0123456789";
await settingsDb.updateSettings({ requireLogin: true });
const policy = await loadPolicy();
const headers = new Headers({
[internalServiceAuth.INTERNAL_SERVICE_AUTH_HEADER]: "internal-service-token-0123456789",
});
const loopback = await policy.evaluate(
ctx(headers, "GET", "/api/combos", { socket: { remoteAddress: "127.0.0.1" } })
);
assert.equal(loopback.allow, true);
const remote = await policy.evaluate(remoteCtx(headers, "GET", "/api/combos"));
assert.equal(remote.allow, false);
delete process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN;
});
test("managementPolicy: rejects client API keys for dashboard access", async () => {
process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy";
process.env.INITIAL_PASSWORD = "initial-pass";

View File

@@ -0,0 +1,66 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
getInternalServiceAuthHeaders,
INTERNAL_SERVICE_AUTH_HEADER,
isInternalServiceRequest,
isTrustedLoopbackInternalServiceRequest,
} from "../../src/lib/api/internalServiceAuth.ts";
import { AUTHZ_HEADER_PEER_LOCALITY } from "../../src/server/authz/headers.ts";
const originalInline = process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN;
const originalFile = process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE;
test.afterEach(() => {
if (originalInline === undefined) delete process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN;
else process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN = originalInline;
if (originalFile === undefined) delete process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE;
else process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE = originalFile;
});
test("internal service auth is disabled when no token is configured", () => {
delete process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN;
delete process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE;
assert.deepEqual(getInternalServiceAuthHeaders(), {});
assert.equal(isInternalServiceRequest(new Request("http://localhost")), false);
});
test("internal service auth preserves a separate constant-time token channel", () => {
process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN = "test-internal-token-0123456789";
delete process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE;
const headers = new Headers({
...getInternalServiceAuthHeaders(),
[AUTHZ_HEADER_PEER_LOCALITY]: "loopback",
});
const request = new Request("http://localhost", { headers });
assert.equal(headers.get(INTERNAL_SERVICE_AUTH_HEADER), "test-internal-token-0123456789");
assert.equal(isInternalServiceRequest(request), true);
assert.equal(isTrustedLoopbackInternalServiceRequest(request), true);
const remote = new Request("https://example.test", {
headers: {
[INTERNAL_SERVICE_AUTH_HEADER]: "test-internal-token-0123456789",
[AUTHZ_HEADER_PEER_LOCALITY]: "remote",
},
});
assert.equal(isTrustedLoopbackInternalServiceRequest(remote), false);
});
test("internal service token file is read without exposing it to process env", () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "omr-internal-auth-"));
const tokenFile = path.join(directory, "token");
try {
fs.writeFileSync(tokenFile, "file-backed-token-0123456789\n", { mode: 0o600 });
delete process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN;
process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE = tokenFile;
assert.deepEqual(getInternalServiceAuthHeaders(), {
[INTERNAL_SERVICE_AUTH_HEADER]: "file-backed-token-0123456789",
});
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});

View File

@@ -34,6 +34,27 @@ test.after(() => {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
} catch {}
delete process.env.INITIAL_PASSWORD;
delete process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN;
});
test("internal service token requires the trusted loopback locality marker", async () => {
process.env.OMNIROUTE_INTERNAL_SERVICE_TOKEN = "internal-service-token-0123456789";
const tokenHeader = "x-omniroute-internal-service-token";
const local = new Request(`${BASE}/api/combos`, {
headers: {
[tokenHeader]: "internal-service-token-0123456789",
"x-omniroute-peer-locality": "loopback",
},
});
assert.equal(await requireManagementAuth(local), null);
const remote = new Request(`${BASE}/api/combos`, {
headers: {
[tokenHeader]: "internal-service-token-0123456789",
"x-omniroute-peer-locality": "remote",
},
});
assert.equal((await requireManagementAuth(remote))?.status, 401);
});
test("read token: allowed on GET, rejected (403) on a write route", async () => {