From b1b0e47697c561a01f62a85dbb6aa0a2270ee9ed Mon Sep 17 00:00:00 2001 From: Xiangzhe Date: Sat, 8 Aug 2026 22:34:35 +0800 Subject: [PATCH] fix(api): enforce model permissions on gateway mirrors --- .../9788-model-catalog-gateway-permissions.md | 1 + open-sse/utils/functionalGatewayMirrors.ts | 11 +- src/app/api/v1/models/catalogResponse.ts | 48 ++++++- ...log-functional-gateway-permissions.test.ts | 118 ++++++++++++++++++ .../models-catalog-functional-gateway.test.ts | 46 ++++++- 5 files changed, 218 insertions(+), 6 deletions(-) create mode 100644 changelog.d/fixes/9788-model-catalog-gateway-permissions.md create mode 100644 tests/unit/models-catalog-functional-gateway-permissions.test.ts diff --git a/changelog.d/fixes/9788-model-catalog-gateway-permissions.md b/changelog.d/fixes/9788-model-catalog-gateway-permissions.md new file mode 100644 index 0000000000..f2218d1d2e --- /dev/null +++ b/changelog.d/fixes/9788-model-catalog-gateway-permissions.md @@ -0,0 +1 @@ +- **fix(api):** Model catalogs no longer expose functional gateway mirrors unless the API key permits the mirror's final public model ID ([#9788](https://github.com/diegosouzapw/OmniRoute/pull/9788)) — thanks @xz-dev diff --git a/open-sse/utils/functionalGatewayMirrors.ts b/open-sse/utils/functionalGatewayMirrors.ts index 5a8cbca65d..2620980153 100644 --- a/open-sse/utils/functionalGatewayMirrors.ts +++ b/open-sse/utils/functionalGatewayMirrors.ts @@ -19,6 +19,8 @@ export const FUNCTIONAL_GATEWAY_MIRROR_SUFFIX = " (via "; +const FUNCTIONAL_GATEWAY_MIRROR = Symbol("functionalGatewayMirror"); + export interface FunctionalGatewayMirrorsDeps { /** Ordered list of passthrough gateway provider ids to consider as mirrors. */ gatewayProviderIds: string[]; @@ -40,9 +42,14 @@ interface GatewayMirrorCatalogEntry { root?: unknown; name?: unknown; display_name?: unknown; + [FUNCTIONAL_GATEWAY_MIRROR]?: true; [key: string]: unknown; } +export function isFunctionalGatewayMirror(model: GatewayMirrorCatalogEntry): boolean { + return model?.[FUNCTIONAL_GATEWAY_MIRROR] === true; +} + /** * Append `/` mirror entries for every eligible model. * Returns the original array reference unchanged when nothing is eligible. @@ -88,14 +95,14 @@ export function appendFunctionalGatewayMirrors>, + apiKey: string, + isModelAllowed: (key: string, modelId: string) => Promise +): Promise>> { + const filtered: Array> = []; + for (const model of models) { + if (!isFunctionalGatewayMirror(model)) { + filtered.push(model); + continue; + } + + if (typeof model.id === "string" && (await isModelAllowed(apiKey, model.id))) { + filtered.push(model); + } + } + return filtered; +} + /** * Enrich the selected models and serialise the catalog response. * @@ -156,12 +185,25 @@ export function applyCatalogPostFilters( * context length for non-combo entries; the quota path passes a no-op because its * entries are all `owned_by: "combo"`, which skips enrichment entirely. */ -export function finalizeCatalogResponse( +export async function finalizeCatalogResponse( request: Request, finalModels: Array>, getContextFallback: (model: Record) => number | undefined, headers: Record -): Response { +): Promise { + const apiKey = extractApiKey(request); + if (apiKey) { + const { getApiKeyMetadata, isModelAllowedForKey } = await import("@/lib/db/apiKeys"); + const keyMeta = await getApiKeyMetadata(apiKey); + if (keyMeta && keyMeta.id !== "env-key" && !keyMeta.allowedQuotas?.length) { + finalModels = await filterUnauthorizedFunctionalGatewayMirrors( + finalModels, + apiKey, + isModelAllowedForKey + ); + } + } + const includeModelNames = isModelCatalogNamesEnabled(); const enrichedModels = disambiguateCatalogModelNames( finalModels.map((model) => { diff --git a/tests/unit/models-catalog-functional-gateway-permissions.test.ts b/tests/unit/models-catalog-functional-gateway-permissions.test.ts new file mode 100644 index 0000000000..ccdcfb1903 --- /dev/null +++ b/tests/unit/models-catalog-functional-gateway-permissions.test.ts @@ -0,0 +1,118 @@ +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"; + +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-model-catalog-gateway-permissions-") +); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "catalog-gateway-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const featureFlagsDb = await import("../../src/lib/db/featureFlags.ts"); +const functionalGatewayDb = await import("../../src/lib/db/functionalGatewayMirrors.ts"); +const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); + +async function resetStorage() { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + v1ModelsCatalog.__resetCatalogBuilderRunsForTest(); +} + +async function seedConnection( + provider: string, + overrides: { + authType?: string; + apiKey?: string | null; + accessToken?: string; + } = {} +) { + return providersDb.createProviderConnection({ + provider, + authType: overrides.authType || "apikey", + name: `${provider}-catalog-permissions`, + apiKey: overrides.apiKey === undefined ? "sk-test" : overrides.apiKey, + accessToken: overrides.accessToken, + isActive: true, + testStatus: "active", + providerSpecificData: {}, + }); +} + +function catalogIds(body: unknown): Set { + if (!body || typeof body !== "object" || !("data" in body) || !Array.isArray(body.data)) { + return new Set(); + } + return new Set( + body.data.flatMap((item) => + item && typeof item === "object" && "id" in item && typeof item.id === "string" + ? [item.id] + : [] + ) + ); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("v1 models catalog requires independent permission for functional gateway mirrors", async () => { + await seedConnection("kimi-coding", { + authType: "oauth", + apiKey: null, + accessToken: "kimi-access", + }); + await seedConnection("agentrouter"); + featureFlagsDb.setFeatureFlagOverride("EXPOSE_FUNCTIONAL_GATEWAY_MIRRORS", "true"); + functionalGatewayDb.setFunctionalGatewayProviderSetting("agentrouter", "on"); + + const restrictedKey = await apiKeysDb.createApiKey( + "catalog-functional-mirror", + "machine-functional" + ); + await apiKeysDb.updateApiKeyPermissions(restrictedKey.id, { + allowedModels: ["kimi-coding/*"], + }); + + const restrictedResponse = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models", { + headers: { Authorization: `Bearer ${restrictedKey.key}` }, + }) + ); + const restrictedIds = catalogIds(await restrictedResponse.json()); + + assert.equal(restrictedResponse.status, 200); + assert.equal(restrictedIds.has("kmc/k3"), true); + assert.equal(restrictedIds.has("agentrouter/kmc/k3"), false); + + const gatewayKey = await apiKeysDb.createApiKey( + "catalog-functional-mirror-allowed", + "machine-functional-allowed" + ); + await apiKeysDb.updateApiKeyPermissions(gatewayKey.id, { + allowedModels: ["kimi-coding/*", "agentrouter/*"], + }); + + const gatewayResponse = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models", { + headers: { Authorization: `Bearer ${gatewayKey.key}` }, + }) + ); + const gatewayIds = catalogIds(await gatewayResponse.json()); + + assert.equal(gatewayResponse.status, 200); + assert.equal(gatewayIds.has("kmc/k3"), true); + assert.equal(gatewayIds.has("agentrouter/kmc/k3"), true); +}); diff --git a/tests/unit/models-catalog-functional-gateway.test.ts b/tests/unit/models-catalog-functional-gateway.test.ts index 99c34c6292..6a3ed07f57 100644 --- a/tests/unit/models-catalog-functional-gateway.test.ts +++ b/tests/unit/models-catalog-functional-gateway.test.ts @@ -1,6 +1,9 @@ import { test, after } from "node:test"; import assert from "node:assert/strict"; -import { applyCatalogPostFilters } from "../../src/app/api/v1/models/catalogResponse.ts"; +import { + applyCatalogPostFilters, + filterUnauthorizedFunctionalGatewayMirrors, +} from "../../src/app/api/v1/models/catalogResponse.ts"; import { removeFeatureFlagOverride, setFeatureFlagOverride, @@ -33,6 +36,47 @@ test("catalog post-filters do not add mirrors when gate off (default)", () => { assert.deepEqual(out, models); }); +test("final catalog permission filtering does not let a mirror inherit base access", async () => { + setFeatureFlagOverride(FLAG_KEY, "true"); + setFunctionalGatewayProviderSetting("agentrouter", "on"); + + const models = [{ id: "kmc/k3", owned_by: "kimi-coding", root: "k3" }]; + const withMirror = applyCatalogPostFilters(makeRequest(), models, { + connections: [ + { + id: "conn-1", + provider: "agentrouter", + isActive: true, + providerSpecificData: {}, + }, + ], + prefixMode: "dual", + aliasToProviderId: {}, + }); + const allowed = await filterUnauthorizedFunctionalGatewayMirrors( + withMirror, + "restricted-key", + async (_key, modelId) => modelId === "kmc/k3" + ); + + assert.deepEqual( + allowed.map((model) => model.id), + ["kmc/k3"], + "a synthesized gateway mirror must authorize its own public ID" + ); + + const gatewayAllowed = await filterUnauthorizedFunctionalGatewayMirrors( + withMirror, + "gateway-key", + async (_key, modelId) => modelId === "agentrouter/kmc/k3" + ); + assert.deepEqual( + gatewayAllowed.map((model) => model.id), + ["kmc/k3", "agentrouter/kmc/k3"], + "an independently authorized gateway mirror must remain visible" + ); +}); + test("catalog post-filters synthesize a gateway mirror when gate on and gateway has a connection", () => { setFeatureFlagOverride(FLAG_KEY, "true"); setFunctionalGatewayProviderSetting("agentrouter", "on");