diff --git a/packages/host/engine/package.json b/packages/host/engine/package.json index 966c8ac4..3259cf9b 100644 --- a/packages/host/engine/package.json +++ b/packages/host/engine/package.json @@ -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", diff --git a/packages/host/engine/src/__tests__/engine-model-probe.test.ts b/packages/host/engine/src/__tests__/engine-model-probe.test.ts index 9a9e0832..1717e405 100644 --- a/packages/host/engine/src/__tests__/engine-model-probe.test.ts +++ b/packages/host/engine/src/__tests__/engine-model-probe.test.ts @@ -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'; @@ -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()), ); }; diff --git a/packages/host/engine/src/__tests__/model-probe.test.ts b/packages/host/engine/src/__tests__/model-probe.test.ts index d55b58dc..f6a2ac5c 100644 --- a/packages/host/engine/src/__tests__/model-probe.test.ts +++ b/packages/host/engine/src/__tests__/model-probe.test.ts @@ -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'; @@ -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 = { @@ -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/; @@ -34,6 +34,11 @@ function jsonResponse(body: unknown): Awaited> { 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. @@ -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((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`), {}), + ).rejects.toThrow(REFUSAL_PATTERN); + expect(received).toBe(0); + } finally { + await new Promise((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 () => { @@ -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); @@ -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 { @@ -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 { diff --git a/packages/host/engine/src/agent/model-probe.ts b/packages/host/engine/src/agent/model-probe.ts index 79411e66..1d2df3a3 100644 --- a/packages/host/engine/src/agent/model-probe.ts +++ b/packages/host/engine/src/agent/model-probe.ts @@ -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); +// `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,61 +113,8 @@ 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 { - 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, - signal?: AbortSignal, -): Promise { - const address = await withAbort(resolvePublicEndpoint(url), signal); - return requestModelListAtAddress(url, headers, address, signal); -} - -function withAbort(promise: Promise, signal?: AbortSignal): Promise { - 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); } @@ -184,13 +122,18 @@ 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, - address: LookupAddress, signal?: AbortSignal, + agent?: HttpAgent | HttpsAgent, ): Promise { 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) => { @@ -198,15 +141,15 @@ export function requestModelListAtAddress( 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) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9339c92..7f65230b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1095,6 +1095,9 @@ importers: '@linkcode/transport': specifier: workspace:* version: link:../../foundation/transport + '@microsoft/antissrf': + specifier: ^1.0.0 + version: 1.0.0 acorn: specifier: ^8.17.0 version: 8.17.0 @@ -3573,6 +3576,9 @@ packages: '@mermaid-js/parser@1.2.0': resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==} + '@microsoft/antissrf@1.0.0': + resolution: {integrity: sha512-AmO1ykSha52Ef/7UXvHgu8ElsGdgcLkF5nTl7YZGyR1AkbmlQYtiVeWp+CsjkFaJzKAH5ofXLZveMmOHwX/7Jw==} + '@mistralai/mistralai@2.2.6': resolution: {integrity: sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==} peerDependencies: @@ -5825,10 +5831,12 @@ packages: '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@xmldom/xmldom@0.9.10': resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} + deprecated: this version has critical issues, please update to the latest version abbrev@4.0.0: resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} @@ -14009,6 +14017,8 @@ snapshots: dependencies: '@chevrotain/types': 11.1.2 + '@microsoft/antissrf@1.0.0': {} + '@mistralai/mistralai@2.2.6(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/semantic-conventions': 1.41.1