-
Notifications
You must be signed in to change notification settings - Fork 7
fix(engine): reach model endpoints through fake-IP tunnels #469
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,15 @@ | ||
| import type { LookupAddress } from 'node:dns'; | ||
| import { lookup as dnsLookup } from 'node:dns/promises'; | ||
| import type { Agent as HttpAgent } from 'node:http'; | ||
| import { request as httpRequest } from 'node:http'; | ||
| import type { Agent as HttpsAgent } from 'node:https'; | ||
| import { request as httpsRequest } from 'node:https'; | ||
| import { BlockList, isIP } from 'node:net'; | ||
| import type { ServiceModelList } from '@linkcode/providers'; | ||
| import type { AccountEndpoint, AccountModel, AccountSecret } from '@linkcode/schema'; | ||
| import { | ||
| AntiSSRFError, | ||
| AntiSSRFPolicy, | ||
| IPAddressRanges, | ||
| PolicyConfigOptions, | ||
| } from '@microsoft/antissrf'; | ||
| import { extractErrorMessage } from 'foxts/extract-error-message'; | ||
| import { z } from 'zod'; | ||
|
|
||
|
|
@@ -17,6 +22,19 @@ import { z } from 'zod'; | |
| * are the bare origin — hence the two different model-list paths below. Both answer | ||
| * `{data:[{id,…}]}`; Anthropic adds `display_name` and paginates (one 1000-entry page is every | ||
| * model any vendor currently serves). | ||
| * | ||
| * **Network boundary.** The URL can be free text, so the daemon must not become a probe of the | ||
| * user's own network. `@microsoft/antissrf` supplies the denied ranges — a table that tracks IANA | ||
| * registries and covers far more than loopback/RFC1918 (cloud metadata, Azure wireserver, AS112, | ||
| * SRv6, Teredo) — and enforces them inside the agent's DNS lookup, so the checked address is the | ||
| * one the socket connects to and a rebind between check and connect has no gap to land in. | ||
| * | ||
| * The one range class re-permitted is the one an RFC forbids real hosts from occupying (2544 | ||
| * benchmarking, 5737/3849 documentation). Nothing routable lives there, so a *name* resolving into | ||
| * it can only be a local resolver's placeholder — which is exactly what a fake-IP tunnel (Clash, | ||
| * sing-box, Surge) hands out for every hostname it proxies. Denying that class buys no protection | ||
| * and strands every user behind such a tunnel; connecting to the placeholder is what hands the | ||
| * request back to the tunnel that minted it. | ||
| */ | ||
|
|
||
| const PROBE_TIMEOUT_MS = 10000; | ||
|
|
@@ -25,46 +43,19 @@ const ERROR_BODY_LIMIT = 200; | |
| const RESPONSE_BODY_LIMIT = 1024 * 1024; | ||
| const MODEL_LIMIT = 10000; | ||
| const TRAILING_SLASH_PATTERN = /\/+$/; | ||
| const BLOCKED_IPV4 = new BlockList(); | ||
| const BLOCKED_IPV6 = new BlockList(); | ||
|
|
||
| for (const [network, prefix] of [ | ||
| ['0.0.0.0', 8], | ||
| ['10.0.0.0', 8], | ||
| ['100.64.0.0', 10], | ||
| ['127.0.0.0', 8], | ||
| ['169.254.0.0', 16], | ||
| ['172.16.0.0', 12], | ||
| ['192.0.0.0', 24], | ||
| ['192.0.2.0', 24], | ||
| ['192.168.0.0', 16], | ||
| ['198.18.0.0', 15], | ||
| ['198.51.100.0', 24], | ||
| ['203.0.113.0', 24], | ||
| ['224.0.0.0', 4], | ||
| ['240.0.0.0', 4], | ||
| ] as const) { | ||
| BLOCKED_IPV4.addSubnet(network, prefix, 'ipv4'); | ||
| } | ||
| for (const [network, prefix] of [ | ||
| ['::', 128], | ||
| ['::1', 128], | ||
| ['::', 96], | ||
| ['::ffff:0:0', 96], | ||
| ['::ffff:0:0:0', 96], | ||
| ['64:ff9b::', 96], | ||
| ['64:ff9b:1::', 48], | ||
| ['100::', 64], | ||
| ['2001:db8::', 32], | ||
| ['2001::', 32], | ||
| ['2002::', 16], | ||
| ['fc00::', 7], | ||
| ['fec0::', 10], | ||
| ['fe80::', 10], | ||
| ['ff00::', 8], | ||
| ] as const) { | ||
| BLOCKED_IPV6.addSubnet(network, prefix, 'ipv6'); | ||
| } | ||
| /** Both refusal kinds the policy raises — a denied address, and plaintext HTTP carrying a secret. */ | ||
| const POLICY_REFUSAL = 'Model detection only reaches public HTTPS endpoints'; | ||
|
|
||
| export const PROBE_POLICY = new AntiSSRFPolicy(PolicyConfigOptions.ExternalOnlyLatest); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Technical details# `ExternalOnlyLatest` injects an invalid `X-Forwarded-For` into every probe
## Affected sites
- `packages/host/engine/src/agent/model-probe.ts:49` — the `PolicyConfigOptions.ExternalOnlyLatest`
constructor arm runs `this._addXFFHeader = true` (alongside
`addDeniedAddresses(IPAddressRanges.recommendedLatest)`).
- Enforced in `AntiSSRFHttpAgent.addRequest` → `_isHttpRequestAllowed`, which calls
`req.setHeader("X-Forwarded-For", "true")` when the header is absent.
Observed server-side for a probe request:
`{"accept":"application/json","x-api-key":"…","host":"…","x-forwarded-for":"true","connection":"close"}`
This reaches every configured model endpoint — vendor APIs, third-party gateways, self-hosted
relays. Note the diff deliberately drops the manual `host` header override while silently gaining
this one.
## Required outcome
The outgoing header set is a deliberate choice, not a side effect of the preset. Either suppress it
(`addXFFHeader` is a public setter, so one line after construction) or keep it with a comment saying
why the probe wants to be seen as proxied.
## Open questions for the human
- Any endpoint doing client-IP-based rate limiting, geo-routing, or strict header validation could
read or reject a malformed XFF. `gateway.linkcode.ai` tolerates it per the PR's verification
table, but that's one endpoint out of the open set a user can configure. |
||
| // `fc00::/18` is sing-box's IPv6 pool; RFC 4193 leaves that half unassigned, so real ULA | ||
| // deployments (Tailscale, Docker) sit in `fd00::/8` and stay denied. | ||
| PROBE_POLICY.addAllowedAddresses([ | ||
| ...IPAddressRanges.benchmarking, | ||
| ...IPAddressRanges.documentation, | ||
| 'fc00::/18', | ||
| ]); | ||
| const PROBE_HTTPS_AGENT = PROBE_POLICY.getHttpsAgent(); | ||
| const PROBE_HTTP_AGENT = PROBE_POLICY.getHttpAgent(); | ||
|
|
||
| /** Tolerant of a relay that answers a bare array instead of the vendors' `{data}` envelope. */ | ||
| const ModelEntrySchema = z.object({ | ||
|
|
@@ -122,91 +113,43 @@ function normalizedHostname(url: URL): string { | |
| return url.hostname[0] === '[' ? url.hostname.slice(1, -1) : url.hostname; | ||
| } | ||
|
|
||
| function isBlockedAddress(address: LookupAddress): boolean { | ||
| return address.family === 4 | ||
| ? BLOCKED_IPV4.check(address.address, 'ipv4') | ||
| : BLOCKED_IPV6.check(address.address, 'ipv6'); | ||
| } | ||
|
|
||
| export async function resolvePublicEndpoint( | ||
| url: URL, | ||
| lookup: typeof dnsLookup = dnsLookup, | ||
| ): Promise<LookupAddress> { | ||
| if (url.protocol !== 'http:' && url.protocol !== 'https:') { | ||
| throw new Error('Model detection requires an HTTP(S) endpoint'); | ||
| } | ||
| const hostname = normalizedHostname(url); | ||
| const family = isIP(hostname); | ||
| const addresses = family | ||
| ? [{ address: hostname, family }] | ||
| : await lookup(hostname, { all: true, verbatim: true }); | ||
| if (addresses.length === 0 || addresses.some(isBlockedAddress)) { | ||
| throw new Error('Model detection cannot access private or non-routable addresses'); | ||
| } | ||
| return addresses[0]; | ||
| } | ||
|
|
||
| export async function requestPublicModelList( | ||
| url: URL, | ||
| headers: Record<string, string>, | ||
| signal?: AbortSignal, | ||
| ): Promise<ModelListResponse> { | ||
| const address = await withAbort(resolvePublicEndpoint(url), signal); | ||
| return requestModelListAtAddress(url, headers, address, signal); | ||
| } | ||
|
|
||
| function withAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> { | ||
| if (!signal) return promise; | ||
| return new Promise((resolve, reject) => { | ||
| const onAbort = () => reject(abortReason(signal)); | ||
| if (signal.aborted) { | ||
| onAbort(); | ||
| return; | ||
| } | ||
| signal.addEventListener('abort', onAbort, { once: true }); | ||
| void promise | ||
| .then((value) => { | ||
| signal.removeEventListener('abort', onAbort); | ||
| resolve(value); | ||
| }) | ||
| .catch((error: unknown) => { | ||
| signal.removeEventListener('abort', onAbort); | ||
| reject(normalizeError(error, 'Model endpoint resolution failed')); | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| function normalizeError(error: unknown, fallback: string): Error { | ||
| if (error instanceof AntiSSRFError) return new Error(POLICY_REFUSAL); | ||
| return new Error(extractErrorMessage(error, false) ?? fallback); | ||
| } | ||
|
|
||
| function abortReason(signal: AbortSignal): Error { | ||
| return normalizeError(signal.reason, 'Model detection aborted'); | ||
| } | ||
|
|
||
| export function requestModelListAtAddress( | ||
| /** `agent` is the network boundary; overriding it is for tests that drive the transport itself. */ | ||
| export function requestPublicModelList( | ||
| url: URL, | ||
| headers: Record<string, string>, | ||
| address: LookupAddress, | ||
| signal?: AbortSignal, | ||
| agent?: HttpAgent | HttpsAgent, | ||
| ): Promise<ModelListResponse> { | ||
| return new Promise((resolve, reject) => { | ||
| if (url.protocol !== 'http:' && url.protocol !== 'https:') { | ||
| reject(new Error('Model detection requires an HTTP(S) endpoint')); | ||
| return; | ||
| } | ||
| let settled = false; | ||
| let responseEnded = false; | ||
| const settleError = (error: unknown) => { | ||
| if (settled) return; | ||
| settled = true; | ||
| reject(normalizeError(error, 'Model-list request failed')); | ||
| }; | ||
| const request = (url.protocol === 'https:' ? httpsRequest : httpRequest)( | ||
| const secure = url.protocol === 'https:'; | ||
| const request = (secure ? httpsRequest : httpRequest)( | ||
| { | ||
| hostname: address.address, | ||
| family: address.family, | ||
| hostname: normalizedHostname(url), | ||
| port: url.port, | ||
| path: `${url.pathname}${url.search}`, | ||
| method: 'GET', | ||
| headers: { ...headers, host: url.host }, | ||
| ...(url.protocol === 'https:' && { servername: normalizedHostname(url) }), | ||
| headers, | ||
| agent: agent ?? (secure ? PROBE_HTTPS_AGENT : PROBE_HTTP_AGENT), | ||
| signal, | ||
| }, | ||
| (response) => { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test never reaches the address gate.
AntiSSRFHttpAgent.addRequestruns the protocol check first, so anhttp://URL is refused withRequest headers or protocol disallowed by policybeforecreateConnectionis ever called —received === 0andREFUSAL_PATTERNboth hold purely because ofallowPlainTextHttp. I proved it: adding127.0.0.0/8,169.254.0.0/16, and192.168.0.0/16toaddAllowedAddressesleaves all 15 tests green. Switching tohttps://fixes it — verified failing under that neutering and passing against the real policy.Technical details