diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index fce29a4460..fa7e0b4b00 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -69,6 +69,7 @@ import { import { redactSecretString } from "../../lib/redact"; import { extractProviderModelItems, + isRegistryModelDiscoveryUrl, readBoundedDiscoveryJson, resolveProviderModelDiscovery, type ModelDiscoveryResponseFailure, @@ -1713,16 +1714,24 @@ async function fetchProviderModelsWithAuth( }; }; try { + // Canonical-URL TUN transparency for Clash/Surge/Mihomo fake-IP DNS: + // `isRegistryModelDiscoveryUrl` proves the FINAL request URL is the + // registry's own fixed discovery URL, so a purely-benchmark DNS answer may + // be pin-connected through the intercepting TUN without proxy env. The + // proof is on the URL — not the provider name — because an OAuth/forward + // name matches any baseUrl by design. Retargeted or renamed custom rows + // fetch a different URL and keep the rejection. + const outboundDependencies = { isCanonicalUrl: isRegistryModelDiscoveryUrl }; const res = request.method === "POST" ? await providerOutboundPost(name, prov, url, { headers, body: JSON.stringify({ project }), signal: AbortSignal.timeout(8000), - }) + }, outboundDependencies) : await providerOutboundGet(name, prov, url, { headers, signal: AbortSignal.timeout(8000), - }); + }, outboundDependencies); const redirectError = await providerRedirectError(res, url); if (redirectError) { const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "http", httpStatus: res.status }); diff --git a/src/lib/provider-outbound.ts b/src/lib/provider-outbound.ts index bfbce5f508..495fef0b8b 100644 --- a/src/lib/provider-outbound.ts +++ b/src/lib/provider-outbound.ts @@ -19,6 +19,14 @@ export interface ProviderOutboundDependencies { resolveAddresses?: typeof resolvePublicAddresses; pinnedGet?: typeof pinnedHttpGet; pinnedPost?: typeof pinnedHttpPost; + /** + * Canonical-URL proof for the transparent fake-IP exception (Clash TUN mode + * without proxy env). Injected so this transport core stays decoupled from + * the registry module; production compares the final request URL against the + * registry's own fixed discovery URL. Defaults to "not canonical" so a caller + * that forgets the seam fails closed, never open. + */ + isCanonicalUrl?: (name: string, url: string) => boolean; } export class ProviderOutboundPolicyError extends Error { @@ -33,6 +41,41 @@ function configuredProxyFor(): boolean { return outboundProxyConfigured(); } +/** + * Registry-owned fake-IP transparency exception (Clash/Surge/Mihomo TUN mode). + * + * Under TUN mode the packet path intercepts the fake-IP destination itself, so a + * canonical registry destination whose local DNS answers include Clash fake-IP + * space (198.18.0.0/15) is reachable by pin-connecting through the TUN — no + * outbound HTTP(S) proxy env is required. The exception is deliberately narrow: + * + * - hostname-only: a literal 198.18.x.x URL never reaches it (the literal gate + * in `resolvePublicAddresses` rejects before DNS answers are examined); + * - canonical-URL-only: `isCanonicalUrl` must prove the FINAL request URL is + * the registry's own fixed discovery URL for this provider (not merely that + * the provider NAME matches — OAuth/forward names match any baseUrl by + * design, and the bearer is pinned to the registry destination independently + * in `buildModelsRequest`). A retargeted row or a renamed custom row sends + * its credential to the registry URL anyway, so the proof must be on the URL + * actually fetched. The check is injected so the transport core stays + * decoupled from the registry module; + * - per-answer validation: benchmark and public answers may coexist. The exception + * does not admit loopback/RFC1918/link-local/metadata companions; those still + * follow the resolver's private-network policy. Benchmark admission leaves + * `privateNetwork` false; proxy/NO_PROXY semantics are unchanged. + * + * Image/Lab fetch never passes the underlying flag and is unaffected. + */ +function transparentFakeIpException( + url: string, + parsed: URL, + isCanonicalUrl: (name: string, url: string) => boolean, + name: string, +): boolean { + if (noProxyMatches(parsed)) return false; + return isCanonicalUrl(name, url); +} + function normalizeProxyHostname(hostname: string): string { const normalized = hostname.trim().toLowerCase().replace(/\.+$/, ""); return normalized.startsWith("[") && normalized.endsWith("]") @@ -147,6 +190,7 @@ async function providerOutboundRequest( const resolveAddresses = dependencies.resolveAddresses ?? resolvePublicAddresses; const pinnedGet = dependencies.pinnedGet ?? pinnedHttpGet; const pinnedPost = dependencies.pinnedPost ?? pinnedHttpPost; + const isCanonicalUrl = dependencies.isCanonicalUrl ?? (() => false); const allowPrivate = providerAllowsPrivateNetwork(name, provider); let resolved: Awaited>; try { @@ -159,7 +203,15 @@ async function providerOutboundRequest( // destination or being pin-connected to the fake-IP (credit #1748). A NO_PROXY // match is a direct route, so it keeps the benchmark answer rejected. Image/Lab // fetch never passes this flag. - allowBenchmarkAddresses: proxyConfigured && !noProxyMatches(parsed), + // + // TUN-mode transparency: with no proxy env, Clash/Surge/Mihomo TUN still + // intercepts the fake-IP destination itself, so the REGISTRY's own fixed + // discovery URL stays reachable by pin-connecting through the TUN. The + // proof is on the final request URL — not the provider name — because an + // OAuth/forward name matches any baseUrl by design while the bearer is + // pinned to the registry destination independently. + allowBenchmarkAddresses: (proxyConfigured && !noProxyMatches(parsed)) + || transparentFakeIpException(url, parsed, isCanonicalUrl, name), // Mihomo IPv6 fake-IP (fdfe:dcba:9876::/48) answers are admitted on a stricter gate // than the benchmark range: the proxy must be the one fetch will use for this URL's // scheme, and the request below is then bound to it explicitly (#3462). A ULA answer diff --git a/src/providers/model-discovery.ts b/src/providers/model-discovery.ts index ada0bd2aec..a0718ac068 100644 --- a/src/providers/model-discovery.ts +++ b/src/providers/model-discovery.ts @@ -158,6 +158,82 @@ function appendDiscoveryQuery(url: URL, query: Readonly> return url; } +/** + * Whether a model-discovery request URL is a registry-owned fixed discovery + * URL — the canonical-URL proof for the transparent fake-IP (Clash/Surge/ + * Mihomo TUN) exception in provider-outbound. + * + * The proof is on the FINAL URL, not the provider name: an OAuth/forward name + * matches any baseUrl by design (`providerMatchesRegistryTransport` returns + * true regardless of destination), while the bearer is pinned to the registry + * destination independently in `buildModelsRequest`. Comparing the fetched URL + * against registry spec URLs keeps a renamed custom row fetching an + * attacker-controlled URL from gaining the exception. + * + * Both spec shapes are covered: an absolute `url` spec matches its own URL + * (plus the spec's fixed query), and a `path` spec matches the URL it resolves + * to against the registry's own baseUrl (plus the spec's fixed query) — so the + * `commandcode` key preset's `path: "models"` proves the same + * `https://api.commandcode.ai/provider/v1/models` string the `command-code` + * OAuth `url` spec proves, and the `nebius` `path: "models"` plus + * `query: { verbose: "true" }` proves + * `https://api.tokenfactory.nebius.com/v1/models?verbose=true`. + * + * Registry-owned fixed query parameters are canonical only on EXACT match: + * a missing, changed, or additional parameter is not canonical, so `?token=` + * smuggling on the right origin+path stays rejected. Fragments are never + * canonical. + */ +export function isRegistryModelDiscoveryUrl(providerName: string, url: string): boolean { + const entry = getProviderRegistryEntry(providerName); + const spec = entry?.modelDiscovery; + if (!spec) return false; + let candidate: URL; + try { + candidate = new URL(url); + } catch { + return false; + } + if (candidate.protocol !== "https:") return false; + if (candidate.username || candidate.password) return false; + if (candidate.hash) return false; + const sameUrl = (canonical: string): boolean => { + let expected: URL; + try { + expected = new URL(canonical); + } catch { + return false; + } + return candidate.origin === expected.origin + && candidate.pathname.replace(/\/+$/, "") === expected.pathname.replace(/\/+$/, "") + && candidate.search === expected.search; + }; + // One shared construction with `resolveProviderModelDiscoveryUrl` below: the + // absolute `url` form carries the spec's fixed query (if any), and the `path` + // form resolves against the REGISTRY's own baseUrl (never a configured one) + // before appending the spec's fixed query. The candidate's own query must + // equal the registry-owned query exactly — no subset/superset matching. + if ("url" in spec && spec.url) { + try { + return sameUrl(appendDiscoveryQuery(new URL(spec.url), spec.query).toString()); + } catch { + return false; + } + } + if ("path" in spec && spec.path) { + try { + const base = new URL(entry.baseUrl.endsWith("/") ? entry.baseUrl : `${entry.baseUrl}/`); + const resolved = spec.path.startsWith("/") + ? new URL(spec.path, base.origin) + : new URL(spec.path, base); + return sameUrl(appendDiscoveryQuery(resolved, spec.query).toString()); + } catch { + return false; + } + } + return false; +} + /** Apply a registry-owned URL/path/query policy to the adapter's normal discovery endpoint. */ export function resolveProviderModelDiscoveryUrl( providerName: string, diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 8b9f8d0dd4..f6a6bf767c 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -45,6 +45,7 @@ import { effectiveGoogleMode, providerCodexAccountMode, providerMatchesRegistryT import { extractModelEnvelopeRows, extractProviderModelItems, + isRegistryModelDiscoveryUrl, readBoundedDiscoveryJson, resolveProviderModelDiscovery, } from "../../providers/model-discovery"; @@ -1236,16 +1237,20 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise => []); +mock.module("node:dns/promises", () => ({ lookup: lookupMock })); + +const { buildModelsRequest } = await import("../../src/oauth"); +const { providerOutboundGet, ProviderOutboundPolicyError } = await import("../../src/lib/provider-outbound"); +const { isRegistryModelDiscoveryUrl } = await import("../../src/providers/model-discovery"); +const { PROXY_ENV_KEYS } = await import("../../src/lib/proxy-env"); +const { gatherRoutedModels, clearGatherRoutedModelsInflight } = await import("../../src/codex/catalog/provider-fetch"); +const { clearModelCache, clearProviderDiscoveryStatus, getProviderDiscoveryStatus } = await import("../../src/codex/model-cache"); +const { withStubbedProviderFetch } = await import("../helpers/catalog-provider-fetch"); +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; + +const FIXTURE = readFileSync(join(import.meta.dir, "../fixtures/commandcode-models.json"), "utf8"); + +const proxyKeys = PROXY_ENV_KEYS.flatMap(key => [key, key.toLowerCase()]); +const originalProxyEnv = Object.fromEntries(proxyKeys.map(key => [key, process.env[key]])); +const originalFetch = globalThis.fetch; + +function clearProxyEnv(): void { + for (const key of proxyKeys) delete process.env[key]; +} + +function canonicalOAuthRow(): OcxProviderConfig { + return { + adapter: "command-code", + baseUrl: "https://api.commandcode.ai", + authMode: "oauth", + liveModels: true, + defaultModel: "deepseek/deepseek-v4-flash", + }; +} + +function canonicalConfig(): OcxConfig { + return { + providers: { + "command-code": { + ...canonicalOAuthRow(), + apiKey: "simulated-oauth-bearer", + }, + }, + } as unknown as OcxConfig; +} + +afterEach(() => { + for (const key of proxyKeys) { + const previous = originalProxyEnv[key]; + if (previous === undefined) delete process.env[key]; + else process.env[key] = previous; + } + globalThis.fetch = originalFetch; + lookupMock.mockReset(); + clearModelCache("command-code"); + clearProviderDiscoveryStatus("command-code"); + clearModelCache("nebius"); + clearProviderDiscoveryStatus("nebius"); + clearGatherRoutedModelsInflight(); +}); + +describe("command-code OAuth discovery under Clash/Mihomo fake-IP DNS", () => { + test("canonical request targets the registry discovery URL with the account bearer", () => { + const request = buildModelsRequest(canonicalOAuthRow(), "account-bearer", "command-code"); + expect(request.url).toBe("https://api.commandcode.ai/provider/v1/models"); + expect(request.headers.Authorization).toBe("Bearer account-bearer"); + expect(isRegistryModelDiscoveryUrl("command-code", request.url)).toBe(true); + }); + + test("canonical URL pin-connects through the TUN without proxy env", async () => { + clearProxyEnv(); + lookupMock.mockResolvedValue([{ address: "198.18.0.29", family: 4 }]); + const request = buildModelsRequest(canonicalOAuthRow(), "account-bearer", "command-code"); + + const response = await providerOutboundGet( + "command-code", + canonicalOAuthRow(), + request.url, + { headers: request.headers }, + { + isCanonicalUrl: isRegistryModelDiscoveryUrl, + pinnedGet: (async (_url, pinned, _signal, requestOptions) => { + expect(pinned.address).toBe("198.18.0.29"); + expect(new Headers(requestOptions?.headers).get("authorization")).toBe("Bearer account-bearer"); + return new Response(FIXTURE, { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as never, + }, + ); + + expect(response.status).toBe(200); + }); + + test("full catalog gather discovers the live OAuth catalog without proxy env", async () => { + clearProxyEnv(); + // Production resolves the OAuth bearer through the OBSERVED auth-store path + // (filesystem evidence -> observedModelsAuthResolver), never the provider + // row. Mirror that here: write a command-code account into an isolated + // OPENCODEX_HOME auth store and gather through the observed entry point. + // The stubbed executor asserts the materialized bearer without exposing it. + const { mkdtempSync, mkdirSync, writeFileSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { gatherRoutedModelsForCatalogGather } = await import("../../src/codex/catalog/provider-fetch"); + const root = mkdtempSync(join(tmpdir(), "ocx-cc-fakeip-")); + const home = join(root, "opencodex"); + mkdirSync(home, { recursive: true }); + const previousHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = home; + const now = Date.now(); + writeFileSync( + join(home, "auth.json"), + JSON.stringify({ + "command-code": { + activeAccountId: "account-1", + accounts: [{ + id: "account-1", + credential: { access: "observed-oauth-bearer", refresh: "r", expires: now + 3_600_000 }, + }], + }, + }) + "\n", + ); + const observedBuffer = new Uint8Array( + await Bun.file(join(home, "auth.json")).arrayBuffer(), + ); + globalThis.fetch = (async (input, init) => { + expect(String(input)).toBe("https://api.commandcode.ai/provider/v1/models"); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer observed-oauth-bearer"); + expect(init?.redirect).toBe("manual"); + return new Response(FIXTURE, { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + try { + const config: OcxConfig = { + providers: { + "command-code": { + adapter: "command-code", + baseUrl: "https://api.commandcode.ai", + authMode: "oauth", + liveModels: true, + defaultModel: "deepseek/deepseek-v4-flash", + }, + }, + }; + const models = await gatherRoutedModelsForCatalogGather( + withStubbedProviderFetch(config), + { authStoreBuffer: observedBuffer }, + ); + const ours = models.filter(model => model.provider === "command-code"); + + expect(ours.length).toBeGreaterThan(1); + expect(ours.map(model => model.id)).toContain("deepseek/deepseek-v4-flash"); + expect(getProviderDiscoveryStatus("command-code")).toEqual({ status: "ok" }); + } finally { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + const { removeTreeWithRetry } = await import("../helpers/remove-tree"); + removeTreeWithRetry(root); + } + }); + + test("literal 198.18.x.x discovery URLs stay rejected", async () => { + clearProxyEnv(); + await expect(providerOutboundGet( + "command-code", + canonicalOAuthRow(), + "https://198.18.0.29/provider/v1/models", + {}, + { isCanonicalUrl: isRegistryModelDiscoveryUrl }, + )).rejects.toBeInstanceOf(ProviderOutboundPolicyError); + expect(lookupMock).not.toHaveBeenCalled(); + }); + + test("loopback / RFC1918 / metadata / link-local companions stay rejected", async () => { + clearProxyEnv(); + for (const address of ["127.0.0.1", "10.0.0.5", "192.168.1.50", "169.254.169.254", "169.254.10.20"]) { + lookupMock.mockResolvedValueOnce([ + { address: "198.18.0.29", family: 4 }, + { address, family: 4 }, + ]); + await expect(providerOutboundGet( + "command-code", + canonicalOAuthRow(), + "https://api.commandcode.ai/provider/v1/models", + {}, + { isCanonicalUrl: isRegistryModelDiscoveryUrl }, + )).rejects.toThrow(ProviderOutboundPolicyError); + } + }); + + test("query/fragment smuggling on the canonical origin+path stays rejected", async () => { + clearProxyEnv(); + for (const url of [ + "https://api.commandcode.ai/provider/v1/models?token=secret", + "https://api.commandcode.ai/provider/v1/models#fragment", + ]) { + expect(isRegistryModelDiscoveryUrl("command-code", url)).toBe(false); + lookupMock.mockResolvedValueOnce([{ address: "198.18.0.29", family: 4 }]); + await expect(providerOutboundGet( + "command-code", + canonicalOAuthRow(), + url, + {}, + { isCanonicalUrl: isRegistryModelDiscoveryUrl }, + )).rejects.toThrow(ProviderOutboundPolicyError); + } + }); + + // CodeRabbit round 1 on PR #3489: the proof blanket-rejected every query, so a + // registry-owned fixed query (Nebius `path: "models"` + `query: { verbose: + // "true" }`) could never receive the TUN exception even though the normal + // discovery resolver appends that exact query to the final request URL. + // Registry-owned fixed queries are canonical ONLY on exact match; anything + // missing, changed, added, or fragmented stays rejected. + test("registry-owned fixed queries match exactly (real Nebius entry)", async () => { + clearProxyEnv(); + const canonical = "https://api.tokenfactory.nebius.com/v1/models?verbose=true"; + expect(isRegistryModelDiscoveryUrl("nebius", canonical)).toBe(true); + // The production request builder must emit exactly the proven URL. + const request = buildModelsRequest( + { adapter: "openai-chat", baseUrl: "https://api.tokenfactory.nebius.com/v1", authMode: "key" }, + "nebius-key", + "nebius", + ); + expect(request.url).toBe(canonical); + + // Canonical query pin-connects through the TUN without proxy env. + lookupMock.mockResolvedValueOnce([{ address: "198.18.0.29", family: 4 }]); + const accepted = await providerOutboundGet( + "nebius", + { baseUrl: "https://api.tokenfactory.nebius.com/v1" }, + canonical, + { headers: request.headers }, + { + isCanonicalUrl: isRegistryModelDiscoveryUrl, + pinnedGet: (async (_url, pinned, _signal, requestOptions) => { + expect(pinned.address).toBe("198.18.0.29"); + expect(new Headers(requestOptions?.headers).get("authorization")).toBe("Bearer nebius-key"); + return new Response(JSON.stringify({ data: [{ id: "moonshotai/Kimi-K3" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as never, + }, + ); + expect(accepted.status).toBe(200); + + // Missing, changed, additional, and fragmented queries stay rejected. + for (const url of [ + "https://api.tokenfactory.nebius.com/v1/models", + "https://api.tokenfactory.nebius.com/v1/models?verbose=false", + "https://api.tokenfactory.nebius.com/v1/models?verbose=true&x=1", + "https://api.tokenfactory.nebius.com/v1/models?verbose=true#fragment", + ]) { + expect(isRegistryModelDiscoveryUrl("nebius", url)).toBe(false); + lookupMock.mockResolvedValueOnce([{ address: "198.18.0.29", family: 4 }]); + await expect(providerOutboundGet( + "nebius", + { baseUrl: "https://api.tokenfactory.nebius.com/v1" }, + url, + {}, + { isCanonicalUrl: isRegistryModelDiscoveryUrl }, + )).rejects.toThrow(ProviderOutboundPolicyError); + } + }); + + test("absolute url specs with a fixed registry query require the exact query", async () => { + const { withRegistryDiscovery } = await import("../helpers/provider-registry-discovery"); + await withRegistryDiscovery("together", { + url: "https://api.together.xyz/v1/catalog", + query: { capability: "chat" }, + }, async () => { + const canonical = "https://api.together.xyz/v1/catalog?capability=chat"; + expect(isRegistryModelDiscoveryUrl("together", canonical)).toBe(true); + expect(isRegistryModelDiscoveryUrl("together", "https://api.together.xyz/v1/catalog")).toBe(false); + expect(isRegistryModelDiscoveryUrl("together", "https://api.together.xyz/v1/catalog?capability=embed")).toBe(false); + expect(isRegistryModelDiscoveryUrl("together", "https://api.together.xyz/v1/catalog?capability=chat&x=1")).toBe(false); + }); + }); + + test("renamed rows fetching an attacker URL gain nothing", async () => { + clearProxyEnv(); + expect(isRegistryModelDiscoveryUrl("renamed-row", "https://api.commandcode.ai/provider/v1/models")).toBe(false); + expect(isRegistryModelDiscoveryUrl("command-code", "https://evil.example/provider/v1/models")).toBe(false); + lookupMock.mockResolvedValueOnce([{ address: "198.18.0.29", family: 4 }]); + await expect(providerOutboundGet( + "renamed-row", + { adapter: "openai-chat", baseUrl: "https://evil.example/v1" }, + "https://evil.example/v1/models", + {}, + { isCanonicalUrl: isRegistryModelDiscoveryUrl }, + )).rejects.toThrow(ProviderOutboundPolicyError); + }); + + test("NO_PROXY-matched canonical hosts keep the rejection (direct route)", async () => { + process.env.HTTPS_PROXY = "http://127.0.0.1:9"; + process.env.NO_PROXY = "api.commandcode.ai"; + process.env.no_proxy = "api.commandcode.ai"; + lookupMock.mockResolvedValueOnce([{ address: "198.18.0.29", family: 4 }]); + await expect(providerOutboundGet( + "command-code", + canonicalOAuthRow(), + "https://api.commandcode.ai/provider/v1/models", + {}, + { isCanonicalUrl: isRegistryModelDiscoveryUrl }, + )).rejects.toThrow(ProviderOutboundPolicyError); + }); + + test("explicit-zero mapped benchmark answers stay covered, hostile tails stay rejected", async () => { + clearProxyEnv(); + lookupMock.mockResolvedValueOnce([{ address: "::ffff:0:c612:1b", family: 6 }]); + const accepted = await providerOutboundGet( + "command-code", + canonicalOAuthRow(), + "https://api.commandcode.ai/provider/v1/models", + {}, + { + isCanonicalUrl: isRegistryModelDiscoveryUrl, + pinnedGet: (async () => new Response(FIXTURE, { + status: 200, + headers: { "content-type": "application/json" }, + })) as never, + }, + ); + expect(accepted.status).toBe(200); + + lookupMock.mockResolvedValueOnce([{ address: "::ffff:0:5db8:d822", family: 6 }]); + await expect(providerOutboundGet( + "command-code", + canonicalOAuthRow(), + "https://api.commandcode.ai/provider/v1/models", + {}, + { isCanonicalUrl: isRegistryModelDiscoveryUrl }, + )).rejects.toThrow(ProviderOutboundPolicyError); + }); + + test("without the canonical-URL proof the fake-IP answer still blocks (fail-closed seam)", async () => { + clearProxyEnv(); + lookupMock.mockResolvedValueOnce([{ address: "198.18.0.29", family: 4 }]); + const request = buildModelsRequest(canonicalOAuthRow(), "account-bearer", "command-code"); + await expect(providerOutboundGet( + "command-code", + canonicalOAuthRow(), + request.url, + { headers: request.headers }, + )).rejects.toThrow(/benchmark address \(198\.18\.0\.29\)/); + }); +}); diff --git a/tests/providers/provider-model-discovery-contract.test.ts b/tests/providers/provider-model-discovery-contract.test.ts index a4a5d6ec43..b55cbcd32e 100644 --- a/tests/providers/provider-model-discovery-contract.test.ts +++ b/tests/providers/provider-model-discovery-contract.test.ts @@ -9,6 +9,7 @@ import { KEY_LOGIN_PROVIDERS, validateApiKey } from "../../src/oauth/key-provide import { deriveKeyLoginMap, providerConfigSeed } from "../../src/providers/derive"; import { extractProviderModelItems, + isRegistryModelDiscoveryUrl, providerModelDiscoverySpecError, readBoundedDiscoveryJson, resolveProviderModelDiscovery, @@ -542,6 +543,65 @@ describe("registry-owned provider model discovery", () => { expect(gateway.modelDiscovery).toBeUndefined(); expect(gateway.liveModels).toBeUndefined(); }); + + // CodeRabbit round 2 on PR #3489 (parity): `resolveProviderModelDiscoveryUrl` + // and `isRegistryModelDiscoveryUrl` resolve `url`/`path`/fixed-query + // independently, so drift between them would silently drop the TUN fake-IP + // exception for a canonical entry (blocked catalog) without any test naming + // the pair. Loop every registry entry that declares `modelDiscovery`: + // resolving its canonical discovery URL must always satisfy the proof. + test("every canonical registry discovery URL satisfies the canonical proof", () => { + const entries = PROVIDER_REGISTRY.filter(entry => entry.modelDiscovery); + expect(entries.length).toBeGreaterThan(0); + for (const entry of entries) { + const seed = providerConfigSeed(entry); + const resolved = resolveProviderModelDiscoveryUrl( + entry.id, + seed, + entry.baseUrl, + `${entry.baseUrl.replace(/\/+$/, "")}/models`, + ); + expect(isRegistryModelDiscoveryUrl(entry.id, resolved)).toBe(true); + } + }); + + // The resolver accepts an effective (possibly custom) baseUrl while the proof + // must stay registry-owned: a custom destination that merely resembles the + // registry shape must NOT gain the benchmark-address exception. Nebius opts + // into `preserveCustomDestination`, so a same-named custom row keeps its own + // destination entirely (no registry query/filter), while the renamed-preset + // fallback recovers the registry policy only for the exact canonical + // destination. Either way the proof is name+URL bound: the attacker-shaped + // URL and the renamed row both fail it. + test("a custom-destination discovery URL is not registry-canonical", () => { + const custom = resolveProviderModelDiscoveryUrl( + "nebius", + { + adapter: "openai-chat", + baseUrl: "https://attacker.example/v1", + authMode: "key", + }, + "https://attacker.example/v1", + "https://attacker.example/v1/models", + ); + expect(custom).toBe("https://attacker.example/v1/models"); + expect(isRegistryModelDiscoveryUrl("nebius", custom)).toBe(false); + expect(isRegistryModelDiscoveryUrl("nebius", "https://attacker.example/v1/models?verbose=true")).toBe(false); + + // The renamed-preset fallback recovers the registry URL for the exact + // canonical destination — but the proof stays name-bound, so a renamed row + // fetching even the canonical string gains no exception. + const renamed = { adapter: "openai-chat", baseUrl: "https://api.tokenfactory.nebius.com/v1", authMode: "key" }; + const renamedUrl = resolveProviderModelDiscoveryUrl( + "nebius-team", + renamed, + "https://api.tokenfactory.nebius.com/v1", + "https://api.tokenfactory.nebius.com/v1/models", + ); + expect(renamedUrl).toBe("https://api.tokenfactory.nebius.com/v1/models?verbose=true"); + expect(isRegistryModelDiscoveryUrl("nebius-team", renamedUrl)).toBe(false); + expect(isRegistryModelDiscoveryUrl("nebius", renamedUrl)).toBe(true); + }); }); describe("same-named custom provider preservation", () => {