diff --git a/lib/client.js b/lib/client.js index 4da4526..bd06d24 100644 --- a/lib/client.js +++ b/lib/client.js @@ -4,13 +4,22 @@ * Desktop 0.2 ships the split MCP client package (v2). Import that package so * link:/Desktop profiles can resolve it from the host graph without a local * `node_modules` copy of the legacy monolith SDK. + * + * HTTP egress follows web-fetch-http: + * - When DSH installed a process proxy policy (`proxyRouteFor` → proxied), + * reuse that dispatcher so HTTPS_PROXY / system proxy is inherited. + * - Otherwise keep the DNS-pinned undici Agent (SSRF-safe direct dial). */ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; import { WebError } from '@deepseek-ai/dsh-web'; import { Agent, fetch as undiciFetch } from 'undici'; import { clampSearchResults } from './catalog.js'; -import { validateHttpEndpoint } from './url-policy.js'; +import { + isNonPublicIpLiteral, + parseHttpEndpoint, + validateHttpEndpoint, +} from './url-policy.js'; /** Run one search through a resolved server entry. */ export async function callMcpSearch(server, key, args, signal) { @@ -23,7 +32,7 @@ export async function callMcpSearch(server, key, args, signal) { let runtime; const client = new Client( - { name: 'dsh-search-mcp', version: '0.2.39' }, + { name: 'dsh-search-mcp', version: '0.2.40' }, { capabilities: {}, versionNegotiation: { mode: 'auto' } }, ); try { @@ -69,10 +78,24 @@ export async function callMcpSearch(server, key, args, signal) { } } -/** Build a DNS-pinned streamable-http transport and its cleanup. */ +/** + * Ask DSH's process-wide proxy policy how to send this URL. + * Soft-import so missing peer does not kill plugin boot. + */ +async function resolveProxyRoute(url) { + try { + const mod = await import('@deepseek-ai/dsh-http-proxy'); + if (typeof mod.proxyRouteFor !== 'function') return { proxied: false }; + return mod.proxyRouteFor(url); + } catch { + return { proxied: false }; + } +} + +/** Build streamable-http transport: inherit proxy when installed, else DNS-pin. */ async function httpRuntime(server, key, signal) { - const validated = await validateHttpEndpoint(server.url, { signal }); - const url = new URL(validated.url); + const parsed = parseHttpEndpoint(server.url); + const url = new URL(parsed.url); const headers = {}; if (key !== undefined && key.length > 0 && server.authParam.length > 0) { const value = `${server.authPrefix ?? ''}${key}`; @@ -80,11 +103,23 @@ async function httpRuntime(server, key, signal) { else if (server.authStyle === 'header') headers[server.authParam] = value; } + // Proxied hops must not pin local DNS — that would dial the origin directly + // and bypass the proxy (same rule as web-fetch-http). + const route = await resolveProxyRoute(url); + if (route.proxied && !isNonPublicIpLiteral(url.hostname)) { + return buildTransport(url, headers, signal, route.dispatcher, async () => {}); + } + + const validated = await validateHttpEndpoint(server.url, { signal }); const agent = new Agent({ connect: { lookup: validated.lookup }, connections: validated.addresses.length, maxRedirections: 0, }); + return buildTransport(url, headers, signal, agent, () => agent.close()); +} + +function buildTransport(url, headers, signal, dispatcher, close) { const expectedOrigin = url.origin; const secureFetch = async (input, init = {}) => { const requestUrl = new URL(typeof input === 'string' || input instanceof URL ? input : input.url); @@ -93,7 +128,7 @@ async function httpRuntime(server, key, signal) { } return undiciFetch(input, { ...init, - dispatcher: agent, + dispatcher, redirect: 'error', ...(signal !== undefined ? { signal: combineSignals(signal, init.signal) } : {}), }); @@ -108,7 +143,7 @@ async function httpRuntime(server, key, signal) { ...(signal !== undefined ? { signal } : {}), }, }), - close: () => agent.close(), + close, }; } diff --git a/lib/url-policy.js b/lib/url-policy.js index c2a365d..f758e7a 100644 --- a/lib/url-policy.js +++ b/lib/url-policy.js @@ -4,11 +4,10 @@ import ipaddr from 'ipaddr.js'; const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']); /** - * Parse and resolve one HTTP endpoint before any request is sent. - * Every resolved address must be globally routable. + * Structural URL checks for one MCP HTTP endpoint (no DNS). + * Used by both the direct (DNS-pinned) path and the proxied path. */ -export async function validateHttpEndpoint(input, options = {}) { - if (options.signal?.aborted) throw abortedPolicyError(); +export function parseHttpEndpoint(input) { if (typeof input !== 'string' || input.length === 0 || input !== input.trim()) { throw policyError('endpoint must be a non-empty canonical URL'); } @@ -36,6 +35,27 @@ export async function validateHttpEndpoint(input, options = {}) { } rejectAmbiguousIpv4(input, comparable); + return Object.freeze({ url, hostname: comparable }); +} + +/** + * True when the host is a literal address that must not go through a local proxy + * (loopback / private / link-local). Matches web-fetch-http's proxy gate. + */ +export function isNonPublicIpLiteral(hostname) { + const unbracketed = stripIpv6Brackets(hostname); + if (!ipaddr.isValid(unbracketed)) return false; + return !isPublicAddress(unbracketed); +} + +/** + * Parse and resolve one HTTP endpoint before any direct (non-proxy) request. + * Every resolved address must be globally routable (or Clash fake-IP). + */ +export async function validateHttpEndpoint(input, options = {}) { + if (options.signal?.aborted) throw abortedPolicyError(); + const { url, hostname: comparable } = parseHttpEndpoint(input); + let addresses; if (ipaddr.isValid(comparable)) { addresses = [{ address: normalizeAddress(comparable), family: addressFamily(comparable) }]; diff --git a/package.json b/package.json index 157ca7b..99df1aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-search-mcp", - "version": "0.2.39", + "version": "0.2.40", "description": "Replace dsh's built-in web search with search MCP servers (Tavily / Brave / Exa / Perplexity / DuckDuckGo / custom), configured from the web Settings page. When this plugin is enabled the built-in DeepSeek search provider is disabled.", "type": "module", "main": "lib/index.js", @@ -55,6 +55,7 @@ "peerDependencies": { "@deepseek-ai/dsh-api-remotes": "*", "@deepseek-ai/dsh-credentials": "*", + "@deepseek-ai/dsh-http-proxy": "*", "@deepseek-ai/dsh-launch-environment": "*", "@deepseek-ai/dsh-settings": "*", "@deepseek-ai/dsh-web": "*", @@ -64,6 +65,9 @@ "undici": "*" }, "peerDependenciesMeta": { + "@deepseek-ai/dsh-http-proxy": { + "optional": true + }, "@modelcontextprotocol/client": { "optional": true }, diff --git a/test/compatibility.test.js b/test/compatibility.test.js index 99ca89e..2304c2a 100644 --- a/test/compatibility.test.js +++ b/test/compatibility.test.js @@ -12,7 +12,7 @@ test('package exports resolve and peerDependencies stay open', async () => { assert.equal(pkg.exports['.'], './lib/index.js') assert.equal(pkg.exports['./client'], './lib/client.browser.js') assert.equal(pkg.engines.node, '>=20') - assert.equal(pkg.version, '0.2.39') + assert.equal(pkg.version, '0.2.40') assert.equal(pkg.dsh.client.immediately, false) assert.equal(pkg.dependencies['@modelcontextprotocol/client'], '2.0.0') assert.ok(!Object.hasOwn(pkg.dependencies, '@modelcontextprotocol/sdk')) @@ -20,6 +20,7 @@ test('package exports resolve and peerDependencies stay open', async () => { for (const name of [ '@deepseek-ai/dsh-api-remotes', '@deepseek-ai/dsh-credentials', + '@deepseek-ai/dsh-http-proxy', '@deepseek-ai/dsh-launch-environment', '@deepseek-ai/dsh-settings', '@deepseek-ai/dsh-web', @@ -85,13 +86,16 @@ test('known providers are CDKey-only while custom keeps advanced fields', async assert.match(client, /已知提供商不需要填写端点链接/) }) -test('HTTP transport pins DNS and applies one guarded fetch to every SDK request', async () => { +test('HTTP transport inherits DSH proxy and pins DNS on the direct path', async () => { const transport = await read('lib/client.js') assert.match(transport, /from '@modelcontextprotocol\/client'/) assert.match(transport, /from '@modelcontextprotocol\/client\/stdio'/) assert.doesNotMatch(transport, /from ['"]@modelcontextprotocol\/sdk/) + assert.match(transport, /@deepseek-ai\/dsh-http-proxy/) + assert.match(transport, /proxyRouteFor/) + assert.match(transport, /parseHttpEndpoint/) assert.match(transport, /validateHttpEndpoint\(server\.url, \{ signal \}\)/) - assert.match(transport, /dispatcher: agent/) + assert.match(transport, /dispatcher/) assert.match(transport, /redirect: 'error'/) assert.match(transport, /versionNegotiation:\s*\{\s*mode:\s*['"]auto['"]/) }) diff --git a/test/url-policy.test.js b/test/url-policy.test.js index 351b354..c28ac61 100644 --- a/test/url-policy.test.js +++ b/test/url-policy.test.js @@ -3,7 +3,9 @@ import assert from 'node:assert/strict'; import { createPinnedLookup, isAllowedEndpointAddress, + isNonPublicIpLiteral, isPublicAddress, + parseHttpEndpoint, validateHttpEndpoint, } from '../lib/url-policy.js'; @@ -12,6 +14,19 @@ const lookup = (records) => (_hostname, options, callback) => { queueMicrotask(() => callback(null, records)); }; +test('parseHttpEndpoint accepts structure without DNS', () => { + const parsed = parseHttpEndpoint('https://search.example/mcp'); + assert.equal(parsed.hostname, 'search.example'); + assert.equal(parsed.url.protocol, 'https:'); +}); + +test('isNonPublicIpLiteral gates loopback and private literals', () => { + assert.equal(isNonPublicIpLiteral('127.0.0.1'), true); + assert.equal(isNonPublicIpLiteral('10.0.0.1'), true); + assert.equal(isNonPublicIpLiteral('8.8.8.8'), false); + assert.equal(isNonPublicIpLiteral('search.example'), false); +}); + test('URL policy accepts HTTP(S) with public DNS only', async () => { const result = await validateHttpEndpoint('https://search.example/mcp', { lookup: lookup([