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
1 change: 1 addition & 0 deletions packages/host/engine/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@linkcode/providers": "workspace:*",
"@linkcode/schema": "workspace:*",
"@linkcode/transport": "workspace:*",
"@microsoft/antissrf": "^1.0.0",
"acorn": "^8.17.0",
"croner": "^10.0.1",
"effect": "4.0.0-beta.98",
Expand Down
7 changes: 4 additions & 3 deletions packages/host/engine/src/__tests__/engine-model-probe.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { Server } from 'node:http';
import { createServer } from 'node:http';
import { Agent, createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
import type { WirePayload } from '@linkcode/schema';
import { nullthrow } from 'foxts/guard';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { probeEndpointModels, requestModelListAtAddress } from '../agent/model-probe';
import { probeEndpointModels, requestPublicModelList } from '../agent/model-probe';
import { InMemoryProviderConfigStore } from '../agent/provider-config';
import { createSessionHarness } from './fixtures/session-harness';

Expand Down Expand Up @@ -42,8 +42,9 @@ let relay: Server | undefined;
const localModelProbe: typeof probeEndpointModels = (source, secret) => {
const resolved = new URL(source.url);
const local = `${baseUrl(nullthrow(relay, 'relay not started'))}${resolved.pathname}${resolved.search}`;
// An unguarded agent: the relay is on loopback, which the probe policy exists to refuse.
return probeEndpointModels({ ...source, url: local }, secret, (url, headers) =>
requestModelListAtAddress(url, headers, { address: '127.0.0.1', family: 4 }),
requestPublicModelList(url, headers, undefined, new Agent()),
);
};

Expand Down
91 changes: 63 additions & 28 deletions packages/host/engine/src/__tests__/model-probe.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createServer } from 'node:http';
import { Agent, createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
import type { ServiceModelList } from '@linkcode/providers';
import type { AccountEndpoint } from '@linkcode/schema';
Expand All @@ -7,9 +7,9 @@ import type { ModelListRequest } from '../agent/model-probe';
import {
modelListHeaders,
modelListUrlFromEndpoint,
PROBE_POLICY,
probeEndpointModels,
requestModelListAtAddress,
resolvePublicEndpoint,
requestPublicModelList,
} from '../agent/model-probe';

const anthropicEndpoint: AccountEndpoint = {
Expand All @@ -24,8 +24,8 @@ const anthropic: ServiceModelList = { url: 'https://relay.test/v1/models', wire:
const openai: ServiceModelList = { url: 'https://relay.test/v1/models', wire: 'openai' };
const REJECTION_PATTERN = /401.*invalid api key/;
const NOT_A_LIST_PATTERN = /did not answer a model list/;
const HTTP_PATTERN = /HTTP/;
const PRIVATE_PATTERN = /private/;
const HTTP_PATTERN = /HTTP\(S\)/;
const REFUSAL_PATTERN = /public HTTPS/;
const EXCEEDED_PATTERN = /exceeded/;
const TIMED_OUT_PATTERN = /timed out/;
const INVALID_ENDPOINT_PATTERN = /cannot contain/;
Expand All @@ -34,6 +34,11 @@ function jsonResponse(body: unknown): Awaited<ReturnType<ModelListRequest>> {
return { status: 200, statusText: 'OK', body: JSON.stringify(body) };
}

/** The transport tests drive a loopback server, which the probe policy exists to refuse. */
function unguarded(): Agent {
return new Agent();
}

describe('custom endpoint model list addressing', () => {
it('appends /v1 for Anthropic-shaped base URLs and only /models for OpenAI-shaped ones', () => {
// Only custom accounts reach this: catalog services carry an explicit URL instead.
Expand Down Expand Up @@ -114,22 +119,49 @@ describe('probeEndpointModels', () => {
});

describe('model probe network boundary', () => {
it('rejects unsupported schemes and private destinations', async () => {
await expect(resolvePublicEndpoint(new URL('file:///tmp/models'))).rejects.toThrow(
it('rejects an unsupported scheme', async () => {
await expect(requestPublicModelList(new URL('file:///tmp/models'), {})).rejects.toThrow(
HTTP_PATTERN,
);
await expect(resolvePublicEndpoint(new URL('http://127.0.0.1/models'))).rejects.toThrow(
PRIVATE_PATTERN,
);
await expect(resolvePublicEndpoint(new URL('http://[::1]/models'))).rejects.toThrow(
PRIVATE_PATTERN,
);
await expect(resolvePublicEndpoint(new URL('http://[fec0::1]/models'))).rejects.toThrow(
PRIVATE_PATTERN,
);
await expect(resolvePublicEndpoint(new URL('http://[64:ff9b::7f00:1]/models'))).rejects.toThrow(
PRIVATE_PATTERN,
);
});

it('never connects to a loopback endpoint', async () => {
let received = 0;
const server = createServer((_request, response) => {
received += 1;
response.end('{}');
});
await new Promise<void>((resolve) => {
server.listen(0, '127.0.0.1', resolve);
});
const port = (server.address() as AddressInfo).port;
try {
await expect(
requestPublicModelList(new URL(`http://127.0.0.1:${port}/models`), {}),

Copy link
Copy Markdown

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.addRequest runs the protocol check first, so an http:// URL is refused with Request headers or protocol disallowed by policy before createConnection is ever called — received === 0 and REFUSAL_PATTERN both hold purely because of allowPlainTextHttp. I proved it: adding 127.0.0.0/8, 169.254.0.0/16, and 192.168.0.0/16 to addAllowedAddresses leaves all 15 tests green. Switching to https:// fixes it — verified failing under that neutering and passing against the real policy.

Technical details
# The address deny-list this PR introduces has no behavioral test

## Affected sites
- `packages/host/engine/src/__tests__/model-probe.test.ts:128-148` — `it('never connects to a
  loopback endpoint')`. `addRequest``_isHttpRequestAllowed` returns false on `http:` +
  `allowPlainTextHttp === false`, emits `AntiSSRFError` on `process.nextTick`, and returns without
  calling `super.addRequest`. `createConnection` and the policy `lookup` never run.
- `packages/host/engine/src/__tests__/model-probe.test.ts:150-161` — asserts `PROBE_POLICY`'s
  `allowedAddresses` / `deniedAddresses` `BlockList`s. A config predicate, not the agent's decision.
- `packages/host/engine/src/__tests__/model-probe.test.ts:163-165` — asserts the
  `allowPlainTextHttp` field, again config not behavior.
- Every remaining transport test passes `unguarded()`, so the guarded agents are exercised by
  nothing.

Net effect: the diff swaps out the entire enforcement mechanism and no test drives it.

## Required outcome
A test that fails if the address policy stops refusing a denied address, covering both gates:
the IP-literal gate in `createConnection` and the policy `lookup` (the gate the doc comment's
"a rebind between check and connect has no gap to land in" claim rests on).

## Suggested approach
The one-character version of this — `http://``https://` on this line — already covers the
IP-literal gate. I ran it: it fails with `write EPROTO … wrong version number` once loopback is
allow-listed (the request connects), and passes against the real policy with `received === 0`.
Adding a `https://localhost:${port}` case covers the DNS gate, since `_lookupAll` refuses when
*any* returned address is denied.

Worth pinning separately: the fake-IP fix depends on antissrf evaluating the allow list *before*
the deny list. That happens in `_isNetworkConnectionAllowed`, which is marked `@internal`, and the
dependency range is `^1.0.0`. If a minor release flipped to deny-first, `198.18.x` would be refused
again and every current test would stay green. A request to an allow-listed-but-unreachable literal
(e.g. `https://198.18.16.15:1/models`) asserting the rejection does *not* match `/public HTTPS/`
would catch that.
Suggested change
requestPublicModelList(new URL(`http://127.0.0.1:${port}/models`), {}),
requestPublicModelList(new URL(`https://127.0.0.1:${port}/models`), {}),

).rejects.toThrow(REFUSAL_PATTERN);
expect(received).toBe(0);
} finally {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});

it('re-permits only the ranges a fake-IP tunnel mints from', () => {
for (const address of ['198.18.16.15', '198.19.255.255', '203.0.113.9']) {
expect(PROBE_POLICY.allowedAddresses.check(address, 'ipv4')).toBe(true);
}
expect(PROBE_POLICY.allowedAddresses.check('fc00::1', 'ipv6')).toBe(true);
// Real ULA is assigned out of `fd00::/8` (Tailscale, Docker) and must stay unreachable.
expect(PROBE_POLICY.allowedAddresses.check('fd00::1', 'ipv6')).toBe(false);
for (const address of ['127.0.0.1', '192.168.1.1', '169.254.169.254', '168.63.129.16']) {
expect(PROBE_POLICY.allowedAddresses.check(address, 'ipv4')).toBe(false);
expect(PROBE_POLICY.deniedAddresses.check(address, 'ipv4')).toBe(true);
}
});

it('refuses to carry a secret over plaintext HTTP', () => {
expect(PROBE_POLICY.allowPlainTextHttp).toBe(false);
});

it('enforces an absolute deadline', async () => {
Expand Down Expand Up @@ -171,10 +203,11 @@ describe('model probe network boundary', () => {
});
const sourcePort = (source.address() as AddressInfo).port;
try {
const result = await requestModelListAtAddress(
new URL(`http://relay.test:${sourcePort}/models`),
const result = await requestPublicModelList(
new URL(`http://127.0.0.1:${sourcePort}/models`),
{ 'x-api-key': 'secret' },
{ address: '127.0.0.1', family: 4 },
undefined,
unguarded(),
);
expect(result.status).toBe(302);
expect(redirectedRequests).toBe(0);
Expand All @@ -200,10 +233,11 @@ describe('model probe network boundary', () => {
const port = (server.address() as AddressInfo).port;
try {
await expect(
requestModelListAtAddress(
new URL(`http://relay.test:${port}/models`),
requestPublicModelList(
new URL(`http://127.0.0.1:${port}/models`),
{},
{ address: '127.0.0.1', family: 4 },
undefined,
unguarded(),
),
).rejects.toThrow(EXCEEDED_PATTERN);
} finally {
Expand All @@ -225,10 +259,11 @@ describe('model probe network boundary', () => {
const port = (server.address() as AddressInfo).port;
try {
await expect(
requestModelListAtAddress(
new URL(`http://relay.test:${port}/models`),
requestPublicModelList(
new URL(`http://127.0.0.1:${port}/models`),
{},
{ address: '127.0.0.1', family: 4 },
undefined,
unguarded(),
),
).rejects.toThrow();
} finally {
Expand Down
151 changes: 47 additions & 104 deletions packages/host/engine/src/agent/model-probe.ts
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';

Expand All @@ -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;
Expand All @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ExternalOnlyLatest also sets addXFFHeader = true, so every probe now sends X-Forwarded-For: true to the user's endpoint — I confirmed it on the wire. That value isn't a valid XFF (the header takes an IP list), and it buys nothing here: antissrf adds it to defeat IMDS, but 169.254.169.254 and 168.63.129.16 are already denied by address and none of the re-permitted ranges host a metadata service. Worth setting PROBE_POLICY.addXFFHeader = false unless it's wanted.

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({
Expand Down Expand Up @@ -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) => {
Expand Down
Loading
Loading