diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index c584b7a75d..9f8a536744 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -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) || {}), + // 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); diff --git a/src/lib/api/internalServiceAuth.ts b/src/lib/api/internalServiceAuth.ts new file mode 100644 index 0000000000..f09cff90f6 --- /dev/null +++ b/src/lib/api/internalServiceAuth.ts @@ -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 { + 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) + ); +} diff --git a/src/lib/api/requireManagementAuth.ts b/src/lib/api/requireManagementAuth.ts index 8540d17c20..eb0dc99239 100644 --- a/src/lib/api/requireManagementAuth.ts +++ b/src/lib/api/requireManagementAuth.ts @@ -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; diff --git a/src/server/authz/policies/management.ts b/src/server/authz/policies/management.ts index ee67cbfca2..a2949f3aa4 100644 --- a/src/server/authz/policies/management.ts +++ b/src/server/authz/policies/management.ts @@ -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" }); } diff --git a/tests/unit/authz/management-policy.test.ts b/tests/unit/authz/management-policy.test.ts index 3f6e05a6b3..2e0e69a558 100644 --- a/tests/unit/authz/management-policy.test.ts +++ b/tests/unit/authz/management-policy.test.ts @@ -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"; diff --git a/tests/unit/internal-service-auth.test.ts b/tests/unit/internal-service-auth.test.ts new file mode 100644 index 0000000000..3ff053ab01 --- /dev/null +++ b/tests/unit/internal-service-auth.test.ts @@ -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 }); + } +}); diff --git a/tests/unit/require-management-auth-access-token.test.ts b/tests/unit/require-management-auth-access-token.test.ts index 985e0d39c1..d99674df78 100644 --- a/tests/unit/require-management-auth-access-token.test.ts +++ b/tests/unit/require-management-auth-access-token.test.ts @@ -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 () => {