From 93da24cd79fe8d1c21a6bd05f076ebb0d825bb1f Mon Sep 17 00:00:00 2001 From: ggdayup Date: Mon, 24 Aug 2026 09:12:38 +0800 Subject: [PATCH] fix(providers): reject reserved provider prefixes on compatible-node create/update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A compatible node created with prefix "tokenrouter" was silently unreachable: the runtime model resolver (src/sse/services/model.ts) skips compatible-node lookup for built-in registry ids/aliases, so "tokenrouter/qwen/..." routed to the built-in tokenrouter provider and failed with "No active credentials for provider: tokenrouter" even though the node itself worked when addressed by its internal id. Reject reserved prefixes at the write path instead: - new shared module src/shared/constants/reservedProviderPrefixes.ts (REGISTRY ids + aliases, case-sensitive, built lazily) — single source of truth consumed by both the runtime guard and the validation schemas so they can never drift apart - createProviderNodeSchema / updateProviderNodeSchema now reject reserved prefixes with a clear message naming the colliding prefix - src/sse/services/model.ts consumes the shared module; runtime behavior is byte-for-byte unchanged (verified e2e) Set semantics mirror the old inline guard exactly: manual alias ids outside REGISTRY (xiaomi/llamacpp/aq) do not intercept nodes at runtime and stay allowed; mixed-case input (TokenRouter) does not collide with the exact-match runtime lookup either. --- .../constants/reservedProviderPrefixes.ts | 70 +++++ src/shared/validation/schemas/provider.ts | 70 +++-- src/sse/services/model.ts | 29 +- .../provider-node-reserved-prefix.test.ts | 254 ++++++++++++++++++ 4 files changed, 379 insertions(+), 44 deletions(-) create mode 100644 src/shared/constants/reservedProviderPrefixes.ts create mode 100644 tests/unit/provider-node-reserved-prefix.test.ts diff --git a/src/shared/constants/reservedProviderPrefixes.ts b/src/shared/constants/reservedProviderPrefixes.ts new file mode 100644 index 0000000000..fb6471b760 --- /dev/null +++ b/src/shared/constants/reservedProviderPrefixes.ts @@ -0,0 +1,70 @@ +// Reserved provider prefixes — single source of truth shared by: +// +// 1. The runtime model resolver guard (src/sse/services/model.ts): user-defined +// compatible-node prefixes must not be allowed to shadow built-in provider +// ids/aliases, otherwise a node with prefix="cf" would hijack cloudflare-ai +// requests (ported from upstream 9router 047fdc89). +// 2. The write-path validation schemas (createProviderNodeSchema / +// updateProviderNodeSchema in src/shared/validation/schemas/provider.ts): +// a prefix that the runtime will never honor must be rejected at creation +// time with a clear message instead of silently routing to the built-in +// provider (tokenrouter bug: "No active credentials for provider: +// tokenrouter" despite a fully configured compatible node). +// +// Semantics (mirror the original inline runtime guard exactly): +// - REGISTRY entry ids + aliases only. Manual alias ids outside REGISTRY +// (xiaomi/llamacpp/aq) do NOT intercept nodes at runtime and are therefore +// deliberately NOT reserved — including them would cause false-positive +// rejections. +// - Case-sensitive: mixed-case input like "TokenRouter" does not collide with +// the runtime lookup (`Set.has` is exact-match), so it stays allowed. +import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts"; + +let _reserved: Set | null = null; + +function buildReservedProviderPrefixes(): Set { + if (_reserved) return _reserved; + const reserved = new Set(); + for (const entry of Object.values(REGISTRY)) { + if (entry?.id) reserved.add(entry.id); + if (entry?.alias) reserved.add(entry.alias); + } + _reserved = reserved; + return reserved; +} + +/** + * All reserved provider prefixes (REGISTRY ids + aliases). Built lazily so the + * registry is only walked once per process. + */ +export function getReservedProviderPrefixes(): ReadonlySet { + return buildReservedProviderPrefixes(); +} + +/** + * Number of unique reserved prefixes (ids + aliases deduplicated). Exposed for + * tests/docs so counts are measured, not memorized. + */ +export const RESERVED_PREFIX_COUNT = buildReservedProviderPrefixes().size; + +/** + * Frozen snapshot of the reserved set (test/documentation convenience). Prefer + * `isReservedProviderPrefix` / `getReservedProviderPrefixes` on hot paths. + */ +export const RESERVED_PROVIDER_PREFIXES: ReadonlySet = getReservedProviderPrefixes(); + +/** + * True when `value` is a reserved provider prefix. Non-strings are never + * reserved (mirrors the runtime guard's typeof check). + */ +export function isReservedProviderPrefix(value: unknown): boolean { + return typeof value === "string" && buildReservedProviderPrefixes().has(value); +} + +/** + * Zod-friendly rejection message for a reserved prefix. Names the colliding + * prefix and tells the operator what to pick instead. + */ +export function reservedProviderPrefixMessage(value: string): string { + return `"${value}" is a reserved provider prefix — choose a different prefix (reserved ids/aliases cannot be used for custom nodes because requests like /model would always route to the built-in provider)`; +} diff --git a/src/shared/validation/schemas/provider.ts b/src/shared/validation/schemas/provider.ts index f92cf78188..7e6cd052b4 100644 --- a/src/shared/validation/schemas/provider.ts +++ b/src/shared/validation/schemas/provider.ts @@ -14,6 +14,10 @@ import { } from "@/shared/constants/upstreamHeaders"; import { MAX_TIMER_TIMEOUT_MS } from "@/shared/utils/runtimeTimeouts"; import { validateProviderSpecificData } from "@/shared/validation/providerSpecificData"; +import { + isReservedProviderPrefix, + reservedProviderPrefixMessage, +} from "@/shared/constants/reservedProviderPrefixes"; import { upstreamHeadersRecordSchema, @@ -367,6 +371,17 @@ export const createProviderNodeSchema = z message: "Prefix is required", path: ["prefix"], }); + } else if (isReservedProviderPrefix(value.prefix.trim())) { + // Reserved-prefix guard (tokenrouter bug): the runtime model resolver skips + // compatible-node lookup for built-in registry ids/aliases, so a node + // created with such a prefix could never be reached by it and silently + // routed requests to the built-in provider instead. Reject at the write + // path. Case-sensitive to match the runtime guard exactly. + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: reservedProviderPrefixMessage(value.prefix.trim()), + path: ["prefix"], + }); } if (nodeType === "openai-compatible" && !value.apiType) { ctx.addIssue({ @@ -377,27 +392,40 @@ export const createProviderNodeSchema = z } }); -export const updateProviderNodeSchema = z.object({ - name: z.string().trim().min(1, "Name is required"), - prefix: z.string().trim().min(1, "Prefix is required"), - apiType: z - .enum([ - "chat", - "responses", - "embeddings", - "audio-transcriptions", - "audio-speech", - "images-generations", - ]) - .optional(), - baseUrl: z.string().trim().min(1, "Base URL is required"), - chatPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")), - modelsPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")), - // #2166: same optional remote icon URL as createProviderNodeSchema — empty string - // clears a previously stored custom icon. - iconUrl: providerNodeIconUrlSchema, - customHeaders: customHeadersSchema, -}); +export const updateProviderNodeSchema = z + .object({ + name: z.string().trim().min(1, "Name is required"), + prefix: z.string().trim().min(1, "Prefix is required"), + apiType: z + .enum([ + "chat", + "responses", + "embeddings", + "audio-transcriptions", + "audio-speech", + "images-generations", + ]) + .optional(), + baseUrl: z.string().trim().min(1, "Base URL is required"), + chatPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")), + modelsPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")), + // #2166: same optional remote icon URL as createProviderNodeSchema — empty string + // clears a previously stored custom icon. + iconUrl: providerNodeIconUrlSchema, + customHeaders: customHeadersSchema, + }) + .superRefine((value, ctx) => { + // Reserved-prefix guard (tokenrouter bug) — same rationale as the guard in + // createProviderNodeSchema: renaming a node's prefix onto a built-in + // registry id/alias would make it unreachable via that prefix. + if (isReservedProviderPrefix(value.prefix)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: reservedProviderPrefixMessage(value.prefix), + path: ["prefix"], + }); + } + }); export const providerNodeValidateSchema = z.object({ baseUrl: z.string().trim().min(1, "Base URL and API key required"), diff --git a/src/sse/services/model.ts b/src/sse/services/model.ts index e733bd9940..c7cbfef9b5 100644 --- a/src/sse/services/model.ts +++ b/src/sse/services/model.ts @@ -20,29 +20,10 @@ import { import { getLearnedReasoningEffortForModel } from "@omniroute/open-sse/services/learnedReasoningEffortCaps.ts"; import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts"; import { getRegisteredProviderEffortBaseModelId } from "@omniroute/open-sse/utils/registeredEffortVariants.ts"; +import { getReservedProviderPrefixes } from "@/shared/constants/reservedProviderPrefixes"; export { parseModel, stripContextWindowSuffix }; -/** - * Reserved provider prefixes — built-in provider ids + aliases. User-defined - * compatible-node prefixes must not be allowed to shadow these, otherwise a - * node with prefix="cf" would hijack cloudflare-ai requests (and similar for - * every built-in provider). Ported from upstream 9router 047fdc89. - * - * Built lazily so the registry is only walked once per process. - */ -let _reservedProviderPrefixes: Set | null = null; -function getReservedProviderPrefixes(): Set { - if (_reservedProviderPrefixes) return _reservedProviderPrefixes; - const reserved = new Set(); - for (const entry of Object.values(REGISTRY)) { - if (entry?.id) reserved.add(entry.id); - if (entry?.alias) reserved.add(entry.alias); - } - _reservedProviderPrefixes = reserved; - return reserved; -} - /** * Fold `settings.wildcardAliases` ({pattern,target}[]) — the store the Settings * UI's "Wildcard Pattern" mode writes to (ModelAliasesUnified.tsx::addWildcardAlias @@ -460,9 +441,11 @@ export async function getModelInfo(modelStr) { // node prefix lookup so the request still routes to the built-in provider. // Internal UUID-prefixed node ids (e.g. "openai-compatible-responses-...") // are never in the reserved set, so the #2778 combo path still works. - // Ported from upstream 9router 047fdc89. - const reserved = getReservedProviderPrefixes(); - const isReservedPrefix = typeof prefixToCheck === "string" && reserved.has(prefixToCheck); + // Ported from upstream 9router 047fdc89. Set shared with the write-path + // validation guard (src/shared/constants/reservedProviderPrefixes.ts) so + // both sides can never drift apart. + const isReservedPrefix = + typeof prefixToCheck === "string" && getReservedProviderPrefixes().has(prefixToCheck); if (!isReservedPrefix) { // Check OpenAI Compatible nodes diff --git a/tests/unit/provider-node-reserved-prefix.test.ts b/tests/unit/provider-node-reserved-prefix.test.ts new file mode 100644 index 0000000000..05d5ad90d7 --- /dev/null +++ b/tests/unit/provider-node-reserved-prefix.test.ts @@ -0,0 +1,254 @@ +// Reserved provider prefixes — compatible-node prefix guard (TDD, tokenrouter bug). +// +// Bug: an operator-created openai-compatible node with prefix "tokenrouter" was +// accepted at creation time, but the runtime model resolver +// (src/sse/services/model.ts) treats built-in registry ids/aliases as reserved +// and skips the node lookup — so `tokenrouter/qwen/...` routed to the BUILT-IN +// tokenrouter provider ("No active credentials for provider: tokenrouter") +// instead of the operator's node. The same node addressed by its internal id +// worked fine. Fix: reject reserved prefixes at the write path (node +// create/update schemas) so the misconfiguration can no longer be created. +// +// The reserved set is shared between the runtime guard and the validation +// schemas via src/shared/constants/reservedProviderPrefixes.ts (single source of +// truth). Set semantics mirror the old inline guard exactly: +// - REGISTRY entry ids + aliases only; +// - case-sensitive (mixed-case "TokenRouter" does NOT collide at runtime); +// - manual alias ids that live outside REGISTRY (xiaomi/llamacpp/aq) are NOT +// included — verified they do not intercept nodes at runtime. +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-reserved-prefix-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providerNodesRoute = await import("../../src/app/api/provider-nodes/route.ts"); +const providerNodesIdRoute = await import("../../src/app/api/provider-nodes/[id]/route.ts"); +const { createProviderNodeSchema, updateProviderNodeSchema } = + await import("../../src/shared/validation/schemas.ts"); +const { RESERVED_PROVIDER_PREFIXES, isReservedProviderPrefix, RESERVED_PREFIX_COUNT } = + await import("../../src/shared/constants/reservedProviderPrefixes.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +// Minimal response-body shapes (no `any` — new eslint violations must be fixed, +// not suppressed). `unknown` fields are narrowed through helpers before use. +type ValidationDetail = { field: string; message: string }; +type ValidationBody = { error?: { details?: ValidationDetail[] } }; +type NodeBody = { node?: { id?: string; prefix?: string } }; + +function asValidationBody(value: unknown): ValidationBody { + return value && typeof value === "object" ? (value as ValidationBody) : {}; +} + +function asNodeBody(value: unknown): NodeBody { + return value && typeof value === "object" ? (value as NodeBody) : {}; +} + +function findPrefixDetail(body: unknown): ValidationDetail | undefined { + const details = asValidationBody(body).error?.details ?? []; + return details.find((d) => d.field === "prefix"); +} + +function makeCreateRequest(body: Record) { + return new Request("http://localhost/api/provider-nodes", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function makeUpdateRequest(id: string, body: Record) { + return new Request(`http://localhost/api/provider-nodes/${id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + await resetStorage(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ──── Shared module ──── + +test("shared set contains REGISTRY ids and aliases (tokenrouter + trk)", () => { + assert.equal(RESERVED_PROVIDER_PREFIXES.has("tokenrouter"), true); + assert.equal(RESERVED_PROVIDER_PREFIXES.has("trk"), true); +}); + +test("shared set is case-sensitive like the runtime guard", () => { + assert.equal(isReservedProviderPrefix("TokenRouter"), false); + assert.equal(isReservedProviderPrefix("TOKENROUTER"), false); + assert.equal(isReservedProviderPrefix("tokenrouter"), true); +}); + +test("shared set excludes manual aliases that never intercept nodes at runtime", () => { + // Verified against src/sse/services/model.ts behavior: xiaomi/llamacpp/aq are + // not REGISTRY members and do NOT shadow compatible nodes, so rejecting them + // would be a false positive. + assert.equal(RESERVED_PROVIDER_PREFIXES.has("qwen"), false); + assert.equal(RESERVED_PROVIDER_PREFIXES.has("xiaomi"), false); + assert.equal(RESERVED_PROVIDER_PREFIXES.has("llamacpp"), false); + assert.equal(RESERVED_PROVIDER_PREFIXES.has("aq"), false); +}); + +test("shared set size matches full REGISTRY scan (329 unique prefixes)", () => { + assert.equal(RESERVED_PREFIX_COUNT, 329); +}); + +test("isReservedProviderPrefix rejects non-string input", () => { + assert.equal(isReservedProviderPrefix(undefined), false); + assert.equal(isReservedProviderPrefix(null), false); + assert.equal(isReservedProviderPrefix(42), false); +}); + +// ──── Schema-level guard ──── + +test("createProviderNodeSchema rejects reserved prefix 'tokenrouter'", () => { + const result = createProviderNodeSchema.safeParse({ + name: "TokenRouter Node", + prefix: "tokenrouter", + apiType: "chat", + baseUrl: "https://api.tokenrouter.com/v1", + }); + assert.equal(result.success, false); + if (!result.success) { + const prefixIssue = result.error.issues.find((i) => i.path[0] === "prefix"); + assert.ok(prefixIssue, "expected a 'prefix' issue"); + assert.match(prefixIssue.message, /reserved/i); + assert.match(prefixIssue.message, /tokenrouter/); + } +}); + +test("createProviderNodeSchema rejects reserved alias 'trk'", () => { + const result = createProviderNodeSchema.safeParse({ + name: "TRK Node", + prefix: "trk", + apiType: "chat", + }); + assert.equal(result.success, false); +}); + +test("createProviderNodeSchema accepts mixed-case 'TokenRouter' (no runtime collision)", () => { + const result = createProviderNodeSchema.safeParse({ + name: "Case Test", + prefix: "TokenRouter", + apiType: "chat", + }); + assert.equal(result.success, true); +}); + +test("createProviderNodeSchema accepts non-reserved prefixes", () => { + for (const prefix of ["my-gateway", "llamacpp", "aq", "xiaomi"]) { + const result = createProviderNodeSchema.safeParse({ + name: "Free Prefix", + prefix, + apiType: "chat", + }); + assert.equal(result.success, true, `prefix "${prefix}" should be accepted`); + } +}); + +test("updateProviderNodeSchema rejects reserved prefix", () => { + const result = updateProviderNodeSchema.safeParse({ + name: "Renamed", + prefix: "openai", + }); + assert.equal(result.success, false); +}); + +test("updateProviderNodeSchema accepts non-reserved prefix", () => { + const result = updateProviderNodeSchema.safeParse({ + name: "Renamed", + prefix: "still-fine", + baseUrl: "https://renamed.example.com/v1", + }); + assert.equal(result.success, true); +}); + +// ──── Route-level guard (POST /api/provider-nodes) ──── + +test("provider nodes route returns 400 with prefix issue for reserved prefix", async () => { + const response = await providerNodesRoute.POST( + makeCreateRequest({ + name: "TokenRouter Node", + prefix: "tokenrouter", + apiType: "chat", + baseUrl: "https://api.tokenrouter.com/v1", + }) + ); + assert.equal(response.status, 400); + const detail = findPrefixDetail(await response.json()); + assert.ok(detail, "expected a prefix validation detail"); + assert.match(detail.message, /reserved/i); +}); + +test("provider nodes route still creates non-reserved nodes", async () => { + const response = await providerNodesRoute.POST( + makeCreateRequest({ + name: "Good Node", + prefix: "good-node", + apiType: "chat", + baseUrl: "https://good.example.com/v1", + }) + ); + assert.equal(response.status, 201); + const body = asNodeBody(await response.json()); + assert.equal(body.node?.prefix, "good-node"); +}); + +// ──── Route-level guard (PUT /api/provider-nodes/[id]) ──── + +test("provider nodes update route rejects renaming prefix to a reserved one", async () => { + const createResponse = await providerNodesRoute.POST( + makeCreateRequest({ + name: "Original Node", + prefix: "original-prefix", + apiType: "chat", + baseUrl: "https://original.example.com/v1", + }) + ); + const created = asNodeBody(await createResponse.json()); + const nodeId = created.node?.id ?? ""; + + const updateResponse = await providerNodesIdRoute.PUT( + makeUpdateRequest(nodeId, { + name: "Hijacked", + prefix: "anthropic", + baseUrl: "https://hijack.example.com/v1", + }), + { params: Promise.resolve({ id: nodeId }) } + ); + assert.equal(updateResponse.status, 400); + const detail = findPrefixDetail(await updateResponse.json()); + assert.ok(detail, "expected a prefix validation detail"); + assert.match(detail.message, /reserved/i); + + // The node keeps its original prefix. + const after = await providerNodesIdRoute.PUT( + makeUpdateRequest(nodeId, { + name: "Still Original", + prefix: "original-prefix", + apiType: "chat", + baseUrl: "https://original.example.com/v1", + }), + { params: Promise.resolve({ id: nodeId }) } + ); + assert.equal(after.status, 200); + const afterBody = asNodeBody(await after.json()); + assert.equal(afterBody.node?.prefix, "original-prefix"); +});