Skip to content
Closed
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
13 changes: 11 additions & 2 deletions src/codex/catalog/provider-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import {
import { redactSecretString } from "../../lib/redact";
import {
extractProviderModelItems,
isRegistryModelDiscoveryUrl,
readBoundedDiscoveryJson,
resolveProviderModelDiscovery,
type ModelDiscoveryResponseFailure,
Expand Down Expand Up @@ -1665,16 +1666,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 });
Expand Down
55 changes: 54 additions & 1 deletion src/lib/provider-outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -33,6 +41,42 @@ 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 answer is ONLY 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;
* - pure-answer-only: `resolvePublicAddresses` admits the exception only when
* EVERY answer is benchmark space; any loopback/RFC1918/link-local/metadata
* companion (or a public+benchmark mix resolving through a rebind) still
* rejects, and `privateNetwork` stays false so proxy/NO_PROXY semantics and
* the private-network gate are unchanged for every other destination.
*
* 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("]")
Expand Down Expand Up @@ -142,6 +186,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<ReturnType<typeof resolvePublicAddresses>>;
try {
Expand All @@ -154,7 +199,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),
});
} catch (error) {
const dnsResolutionFailed = error instanceof DestinationDnsResolutionError
Expand Down
76 changes: 76 additions & 0 deletions src/providers/model-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,82 @@ function appendDiscoveryQuery(url: URL, query: Readonly<Record<string, string>>
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);
Comment thread
Ingwannu marked this conversation as resolved.
} 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;
}
}
Comment thread
Flowershangfromthebranches marked this conversation as resolved.
Comment thread
Ingwannu marked this conversation as resolved.
return false;
}

/** Apply a registry-owned URL/path/query policy to the adapter's normal discovery endpoint. */
export function resolveProviderModelDiscoveryUrl(
providerName: string,
Expand Down
9 changes: 7 additions & 2 deletions src/server/management/provider-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { effectiveGoogleMode, providerCodexAccountMode, providerMatchesRegistryT
import {
extractModelEnvelopeRows,
extractProviderModelItems,
isRegistryModelDiscoveryUrl,
readBoundedDiscoveryJson,
resolveProviderModelDiscovery,
} from "../../providers/model-discovery";
Expand Down Expand Up @@ -1236,16 +1237,20 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
const discovery = resolveProviderModelDiscovery(name, prov);
const started = Date.now();
try {
// Same canonical-URL TUN transparency as catalog discovery: the registry's
// own fixed discovery URL survives purely-benchmark (Clash/Surge/Mihomo
// fake-IP) DNS without proxy env.
const outboundDependencies = { isCanonicalUrl: isRegistryModelDiscoveryUrl };
const res = method === "POST"
? await providerOutboundPost(name, prov, modelsUrl, {
headers,
body: JSON.stringify({ project }),
signal: AbortSignal.timeout(8000),
})
}, outboundDependencies)
: await providerOutboundGet(name, prov, modelsUrl, {
headers,
signal: AbortSignal.timeout(8000),
});
}, outboundDependencies);
const latencyMs = Date.now() - started;
const redirectError = await providerRedirectError(res, modelsUrl);
if (redirectError) {
Expand Down
Loading
Loading