mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
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:
@@ -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);
|
||||
|
||||
39
src/lib/api/internalServiceAuth.ts
Normal file
39
src/lib/api/internalServiceAuth.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
66
tests/unit/internal-service-auth.test.ts
Normal file
66
tests/unit/internal-service-auth.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user