Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(api):** `/v1/models` no longer lists another provider's models to a restricted API key — an aggregator row whose `root` carries a vendor namespace (cline's `deepseek/deepseek-v4-flash`, `openrouter/free`) no longer matches an allowlist entry meant for that vendor, and a blocked `id` is no longer re-admitted through its `root` ([#15409](https://github.com/diegosouzapw/OmniRoute/issues/15409)) — thanks @yourspraveen
13 changes: 4 additions & 9 deletions src/app/api/v1/models/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1991,7 +1991,8 @@ async function buildUnifiedModelsResponseCore(
const apiKey = extractApiKey(request);
let finalModels = models;
if (apiKey) {
const { isModelAllowedForKey, getApiKeyMetadata } = await import("@/lib/db/apiKeys");
const { getApiKeyMetadata } = await import("@/lib/db/apiKeys");
const { isCatalogModelAllowedForKey } = await import("./catalogKeyFilter");

// Quota-exclusive keys (allowedQuotas non-empty): list ONLY the pool's qtSd/*
// virtual models. #4806: build from the hidden qtSd/* combos directly — the base
Expand Down Expand Up @@ -2036,14 +2037,8 @@ async function buildUnifiedModelsResponseCore(
}
continue;
}
// m.id is the full identifier (e.g. openai/gpt-4o), m.root is the raw model string
// check either one as the config could use either patterns
if (
(await isModelAllowedForKey(apiKey, m.id)) ||
(await isModelAllowedForKey(apiKey, m.root))
) {
filtered.push(m);
}
// m.id decides; a bare m.root also matches a bare allowlist entry (#781, #15409).
if (await isCatalogModelAllowedForKey(apiKey, m, keyMeta.blockedModels)) filtered.push(m);
}
finalModels = filtered;
}
Expand Down
34 changes: 34 additions & 0 deletions src/app/api/v1/models/catalogKeyFilter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
type CatalogKeyFilterDeps = {
isModelAllowedForKey: (key: string, modelId: string) => Promise<boolean>;
isModelBlockedByPatterns: (
blockedModels: string[] | null | undefined,
modelId: string
) => Promise<boolean>;
};

/**
* Decide whether a provider-model catalog row is visible to a restricted API key.
*
* The row's `id` is the authoritative identifier. Its `root` is also consulted so a
* bare allowlist entry (e.g. `gpt-4o`) keeps matching `openai/gpt-4o` (#781) — but
* only when the root is bare. `root` is a raw upstream string with no owning-provider
* namespace: an aggregator row such as `cline/deepseek/deepseek-v4-flash` carries
* `root = deepseek/deepseek-v4-flash`, which the permission check would read as the
* deepseek provider's model and list for a key that never allowed cline (#15409).
* A row whose `id` is blocked is never brought back through its root.
*/
export async function isCatalogModelAllowedForKey(
apiKey: string,
model: { id?: unknown; root?: unknown },
blockedModels: string[] | null | undefined,
deps?: CatalogKeyFilterDeps
): Promise<boolean> {
deps ??= await import("@/lib/db/apiKeys");
const id = typeof model.id === "string" ? model.id : "";
if (await deps.isModelAllowedForKey(apiKey, id)) return true;

const root = typeof model.root === "string" ? model.root : "";
if (!root || root === id || root.includes("/")) return false;
if (await deps.isModelBlockedByPatterns(blockedModels, id)) return false;
return deps.isModelAllowedForKey(apiKey, root);
}
128 changes: 128 additions & 0 deletions tests/unit/api-key-allowlist-root-namespace-15409.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* #15409: a restricted key's /v1/models must not list another provider's models.
*
* The catalog filter also checked each row's `root` so a bare allowlist entry
* (`gpt-4o`) keeps matching `openai/gpt-4o` (#781). But `root` is a raw upstream
* string with no owning-provider namespace: aggregators such as cline expose
* `id = cline/deepseek/deepseek-v4-flash` with `root = deepseek/deepseek-v4-flash`,
* and the permission check reads that root as the deepseek provider's model —
* so a key allowing only `ds/deepseek-v4-flash` listed the cline row.
*
* Rules:
* R1 A namespaced root never grants access to a row whose id is not allowed.
* R2 The collision is generic — any provider wildcard (openrouter/*) is affected.
* R3 A bare root still matches a bare allowlist entry (#781 behaviour kept).
* R4 A row whose id is blocked is not brought back through its root.
* R5 The catalog filter goes through the helper, not a raw id-or-root check.
*/

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-root-ns-15409-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = "test-api-key-secret-root-ns-15409";
delete process.env.INITIAL_PASSWORD;
delete process.env.JWT_SECRET;

const core = await import("../../src/lib/db/core.ts");
const apiKeys = await import("../../src/lib/db/apiKeys.ts");
const { isCatalogModelAllowedForKey } =
await import("../../src/app/api/v1/models/catalogKeyFilter.ts");

test.after(() => {
apiKeys.resetApiKeyState();
core.resetDbInstance();
try {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch {
// best-effort cleanup
}
});

async function restrictedKey(name: string, allowedModels: string[], blockedModels: string[] = []) {
const created = await apiKeys.createApiKey(name, `machine-${name}`);
await apiKeys.updateApiKeyPermissions(created.id, {
modelAccessMode: "restricted",
allowedModels,
blockedModels,
} as Parameters<typeof apiKeys.updateApiKeyPermissions>[1]);
apiKeys.resetApiKeyState();
return { key: created.key, blockedModels };
}

async function visible(
key: { key: string; blockedModels: string[] },
model: { id: string; root: string }
) {
return isCatalogModelAllowedForKey(key.key, model, key.blockedModels, apiKeys);
}

test("R1: a namespaced root does not leak a cline row into a deepseek-only key", async () => {
const key = await restrictedKey("ds-only", ["ds/deepseek-v4-flash"]);

assert.equal(
await visible(key, {
id: "cline/deepseek/deepseek-v4-flash",
root: "deepseek/deepseek-v4-flash",
}),
false,
"cline's row must not be listed by a key that never allowed cline"
);
assert.equal(
await visible(key, { id: "cl/deepseek/deepseek-v4-flash", root: "deepseek/deepseek-v4-flash" }),
false,
"the cline alias row must not be listed either"
);
// The deepseek provider's own row is still listed.
assert.equal(
await visible(key, { id: "deepseek/deepseek-v4-flash", root: "deepseek-v4-flash" }),
true
);
});

test("R2: the leak is generic — a provider wildcard does not admit an aggregator's row", async () => {
const key = await restrictedKey("openrouter-only", ["openrouter/*"]);

assert.equal(await visible(key, { id: "cline/openrouter/free", root: "openrouter/free" }), false);
assert.equal(await visible(key, { id: "cl/openrouter/free", root: "openrouter/free" }), false);
});

test("R3: a bare root still matches a bare allowlist entry (#781)", async () => {
const key = await restrictedKey("bare", ["gpt-4o"]);

assert.equal(await visible(key, { id: "openai/gpt-4o", root: "gpt-4o" }), true);
assert.equal(await visible(key, { id: "openai/gpt-4o-mini", root: "gpt-4o-mini" }), false);
});

test("R4: a blocked id is not brought back through its root", async () => {
const key = await restrictedKey("bare-blocked", ["deepseek-v4-flash"], ["cline/*"]);

assert.equal(
await visible(key, { id: "cline/deepseek-v4-flash", root: "deepseek-v4-flash" }),
false,
"blockedModels must win over the bare-root match"
);
assert.equal(
await visible(key, { id: "deepseek/deepseek-v4-flash", root: "deepseek-v4-flash" }),
true
);
});

test("R5: the catalog filters provider rows through isCatalogModelAllowedForKey", () => {
const catalog = fs.readFileSync(
path.join(process.cwd(), "src/app/api/v1/models/catalog.ts"),
"utf8"
);
assert.ok(
catalog.includes("isCatalogModelAllowedForKey("),
"catalog.ts must decide provider-row visibility via isCatalogModelAllowedForKey"
);
assert.ok(
!catalog.includes("isModelAllowedForKey(apiKey, m.root)"),
"the raw root check must not come back"
);
});
Loading