diff --git a/packages/web/tool-web/src/index.ts b/packages/web/tool-web/src/index.ts index 072fc6018e..df8029466a 100644 --- a/packages/web/tool-web/src/index.ts +++ b/packages/web/tool-web/src/index.ts @@ -55,4 +55,3 @@ export function apply(ctx: Context, config: Config): void { if (config.search !== false) applyWebSearchTool(ctx) if (config.fetch !== false) applyWebFetchTool(ctx) } - diff --git a/packages/web/tool-web/tests/integration.spec.ts b/packages/web/tool-web/tests/integration.spec.ts index 18604190c6..50ae6c5624 100644 --- a/packages/web/tool-web/tests/integration.spec.ts +++ b/packages/web/tool-web/tests/integration.spec.ts @@ -96,4 +96,3 @@ describe('web_search integration over the real Exa provider', () => { expect(out.content.map(b => b.text).join('')).toContain('[Result](https://result.test)') }) }) - diff --git a/packages/web/web-fetch-local/README.md b/packages/web/web-fetch-local/README.md index fd9150e46f..58db557581 100644 --- a/packages/web/web-fetch-local/README.md +++ b/packages/web/web-fetch-local/README.md @@ -26,9 +26,11 @@ The provider owns **safe resource retrieval**: URL validation, HTTP transport, r | `maxBodyChars` | `100_000` | Maximum decoded body length in characters. | | `timeoutMs` | `30_000` | Default fetch timeout. | | `maxTimeoutMs` | `120_000` | Upper bound for a per-request timeout override. | -| `maxRedirects` | `5` | Maximum same-origin redirect hops. | +| `maxRedirects` | `5` | Maximum same-origin redirect hops (`0` follows none). | | `userAgent` | `deepseek-harness/…` | `User-Agent` header. | +The numeric limits are validated at plugin construction: every cap except `maxRedirects` must be a positive finite number, and `maxRedirects` must be a non-negative integer. An invalid value throws rather than silently constructing a provider with nonsensical limits. + ## Security note SSRF / private-network protection (blocking private, loopback, link-local, multicast, and otherwise non-public destinations, with DNS-resolve-then-validate and per-hop re-validation) is **deferred** — see the [web capability seam RFC](../../../docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md). Until it lands, this provider is an SSRF primitive and **must not be enabled** in a deployment that can reach sensitive internal network targets. diff --git a/packages/web/web-fetch-local/src/index.ts b/packages/web/web-fetch-local/src/index.ts index eb3f8e4143..7eb614f39a 100644 --- a/packages/web/web-fetch-local/src/index.ts +++ b/packages/web/web-fetch-local/src/index.ts @@ -60,10 +60,30 @@ export const Config: z = z.object({ /** The shape after schemastery applies its defaults to every field. */ type ResolvedConfig = Required +/** A resource limit (byte/char/length/timeout cap) must be a positive finite number. */ +function assertPositiveFinite(name: string, value: number): void { + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`web-fetch-local: ${name} must be a positive finite number`) + } +} + +/** The redirect hop cap must be a non-negative integer (0 follows no redirects). */ +function assertNonNegativeInteger(name: string, value: number): void { + if (!Number.isInteger(value) || value < 0) { + throw new Error(`web-fetch-local: ${name} must be a non-negative integer`) + } +} + /** Register the local HTTP(S) fetch provider with `ctx.web`. */ export function apply(ctx: Context, config: Config): void { // schemastery (Config) has already filled every defaulted field. const resolved = config as ResolvedConfig + assertPositiveFinite('maxUrlLength', resolved.maxUrlLength) + assertPositiveFinite('maxResponseBytes', resolved.maxResponseBytes) + assertPositiveFinite('maxBodyChars', resolved.maxBodyChars) + assertPositiveFinite('timeoutMs', resolved.timeoutMs) + assertPositiveFinite('maxTimeoutMs', resolved.maxTimeoutMs) + assertNonNegativeInteger('maxRedirects', resolved.maxRedirects) const limits: LocalFetchLimits = { maxUrlLength: resolved.maxUrlLength, maxResponseBytes: resolved.maxResponseBytes, diff --git a/packages/web/web-fetch-local/src/provider.ts b/packages/web/web-fetch-local/src/provider.ts index 061eaf5e0c..29b183b710 100644 --- a/packages/web/web-fetch-local/src/provider.ts +++ b/packages/web/web-fetch-local/src/provider.ts @@ -81,11 +81,21 @@ export class LocalFetchProvider implements WebFetchProvider { /** Follow same-origin redirects up to the hop cap, then read the final response. */ private async followAndRead(initialUrl: string, controller: AbortController): Promise { let currentUrl = validateFetchUrl(initialUrl, this.limits.maxUrlLength) + let redirectsFollowed = 0 - for (let hop = 0; hop <= this.limits.maxRedirects; hop++) { + for (;;) { const response = await this.requestOnce(currentUrl, controller) if (isRedirectStatus(response.status)) { + // The redirect budget is enforced BEFORE this hop's target is resolved + // or origin-checked, so `maxRedirects: N` follows at most N redirects + // exactly: the (N+1)th redirect is refused as "exceeded" regardless of + // where it points (a same-origin/cross-origin distinction on a hop we + // are not allowed to follow would be the wrong diagnosis). + if (redirectsFollowed >= this.limits.maxRedirects) { + await response.body?.cancel() + throw new WebError(`exceeded the maximum of ${this.limits.maxRedirects} redirects`, 'WEB_REDIRECT_BLOCKED') + } const location = response.headers.get('location') if (location === null) { // A redirect status with no Location is not a usable resource. Cancel @@ -113,13 +123,12 @@ export class LocalFetchProvider implements WebFetchProvider { } await response.body?.cancel() currentUrl = validatedTarget + redirectsFollowed++ continue } return await this.readBody(response, currentUrl, controller.signal) } - - throw new WebError(`exceeded the maximum of ${this.limits.maxRedirects} redirects`, 'WEB_REDIRECT_BLOCKED') } private async requestOnce(url: URL, controller: AbortController): Promise { diff --git a/packages/web/web-fetch-local/tests/fetch-local.spec.ts b/packages/web/web-fetch-local/tests/fetch-local.spec.ts index 75a7cb1580..27ed991c08 100644 --- a/packages/web/web-fetch-local/tests/fetch-local.spec.ts +++ b/packages/web/web-fetch-local/tests/fetch-local.spec.ts @@ -203,6 +203,60 @@ describe('LocalFetchProvider redirects', () => { .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED' })) }) + it('follows exactly maxRedirects hops: a chain landing on the Nth redirect succeeds', async () => { + // maxRedirects: 2 → /?n=0 → /?n=1 → /?n=2(200). Exactly 2 redirects + 1 + // final = 3 requests; the cap is inclusive of the landing request. + let requests = 0 + handler = (req, res) => { + requests++ + const n = Number(new URL(req.url ?? '/', base).searchParams.get('n') ?? '0') + if (n >= 2) { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('landed') } + else { res.writeHead(302, { location: `/?n=${n + 1}` }); res.end() } + } + const result = await provider({ maxRedirects: 2 }).fetch({ url: `${base}/?n=0` }) + expect(result.body.content).toBe('landed') + expect(requests).toBe(3) + }) + + it('makes exactly maxRedirects+1 requests before blocking an over-long chain', async () => { + // maxRedirects: 2 on an infinite chain: requests at n=0,1,2 (the 3rd is the + // over-limit redirect, refused before its Location is followed) = 3 total. + let requests = 0 + handler = (req, res) => { + requests++ + const n = Number(new URL(req.url ?? '/', base).searchParams.get('n') ?? '0') + res.writeHead(302, { location: `/?n=${n + 1}` }) + res.end() + } + await expect(provider({ maxRedirects: 2 }).fetch({ url: `${base}/?n=0` })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED', message: 'exceeded the maximum of 2 redirects' })) + expect(requests).toBe(3) + }) + + it('reports an over-limit redirect as "exceeded", not cross-origin, even when the over-limit hop points cross-origin', async () => { + // The redirect budget is checked BEFORE the over-limit hop's target is + // origin-validated, so the diagnosis is "exceeded", not "cross-origin". + handler = (req, res) => { + const n = Number(new URL(req.url ?? '/', base).searchParams.get('n') ?? '0') + const location = n === 0 ? '/?n=1' : 'https://example.com/' + res.writeHead(302, { location }) + res.end() + } + await expect(provider({ maxRedirects: 1 }).fetch({ url: `${base}/?n=0` })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED', message: 'exceeded the maximum of 1 redirects' })) + }) + + it('maxRedirects: 0 follows no redirect but still fetches a direct 200', async () => { + handler = (req, res) => { + if (req.url === '/r') { res.writeHead(302, { location: '/done' }); res.end() } + else { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('direct') } + } + await expect(provider({ maxRedirects: 0 }).fetch({ url: `${base}/r` })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED' })) + const direct = await provider({ maxRedirects: 0 }).fetch({ url: `${base}/done` }) + expect(direct.body.content).toBe('direct') + }) + it('treats a redirect without a Location header as a provider error', async () => { handler = (_req, res) => { res.writeHead(302); res.end() } await expect(provider().fetch({ url: base })) @@ -331,4 +385,40 @@ describe('web-fetch-local plugin registration', () => { it('has no default export (namespace plugin export shape)', () => { expect('default' in fetchPlugin).toBe(false) }) + + it('rejects a non-positive resource limit at construction', async () => { + const ctx = new Context() + await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID }) + await expect(ctx.plugin(fetchPlugin, { maxResponseBytes: -1 })) + .rejects.toThrow(/maxResponseBytes must be a positive finite number/) + }) + + it('rejects a zero timeout at construction', async () => { + const ctx = new Context() + await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID }) + await expect(ctx.plugin(fetchPlugin, { timeoutMs: 0 })) + .rejects.toThrow(/timeoutMs must be a positive finite number/) + }) + + it('rejects a fractional redirect cap at construction', async () => { + const ctx = new Context() + await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID }) + await expect(ctx.plugin(fetchPlugin, { maxRedirects: 1.5 })) + .rejects.toThrow(/maxRedirects must be a non-negative integer/) + }) + + it('rejects a negative redirect cap at construction', async () => { + const ctx = new Context() + await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID }) + await expect(ctx.plugin(fetchPlugin, { maxRedirects: -1 })) + .rejects.toThrow(/maxRedirects must be a non-negative integer/) + }) + + it('accepts maxRedirects: 0 (follow no redirects) as valid config', async () => { + const ctx = new Context() + await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID }) + const fiber = await ctx.plugin(fetchPlugin, { maxRedirects: 0 }) + expect(ctx.web.fetchStatus()).toEqual({ available: true, providerId: LOCAL_FETCH_PROVIDER_ID }) + await fiber.dispose() + }) }) diff --git a/packages/web/web-search-deepseek/src/provider.ts b/packages/web/web-search-deepseek/src/provider.ts index 5d02ad01ab..b637d52d11 100644 --- a/packages/web/web-search-deepseek/src/provider.ts +++ b/packages/web/web-search-deepseek/src/provider.ts @@ -200,14 +200,14 @@ export class DeepSeekSearchProvider implements WebSearchProvider { throw new WebError(message, 'WEB_PROVIDER_ERROR') } - let payload: AnthropicResponse try { - payload = await response.json() as AnthropicResponse + const payload = await response.json() as AnthropicResponse + return mapAnthropicResponse(request.query, payload) } catch (error: unknown) { if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error }) - throw new WebError(`DeepSeek returned an unparseable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) + if (error instanceof WebError) throw error + throw new WebError(`DeepSeek returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) } - return mapAnthropicResponse(request.query, payload) } } diff --git a/packages/web/web-search-deepseek/tests/deepseek.spec.ts b/packages/web/web-search-deepseek/tests/deepseek.spec.ts index ff6b37cfa2..496b7f6a3c 100644 --- a/packages/web/web-search-deepseek/tests/deepseek.spec.ts +++ b/packages/web/web-search-deepseek/tests/deepseek.spec.ts @@ -220,6 +220,12 @@ describe('DeepSeekSearchProvider error handling', () => { .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) }) + it('maps a well-formed body of the wrong shape to WEB_PROVIDER_ERROR, not a raw TypeError', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ content: {} }, { status: 200 }))) + await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) + it('surfaces an abort during success-body parse as WEB_ABORTED', async () => { const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: true, status: 200 } vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response)) diff --git a/packages/web/web-search-exa/README.md b/packages/web/web-search-exa/README.md index 61da605df7..6485d64c60 100644 --- a/packages/web/web-search-exa/README.md +++ b/packages/web/web-search-exa/README.md @@ -20,4 +20,4 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i ## Mapping -Exa returns a flat `results[]` and no generated answer, so `content` is omitted. Each result maps to a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← the first non-empty `highlights[]` entry (a result with no highlight has no portable snippet and is dropped), `publishedAt` ← `publishedDate`. The provider passes `maxResults` through as Exa's `numResults` for a cost/latency optimization; the final bound is enforced by the seam. Provider failures (HTTP errors, network failure, unparseable bodies) surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`. +Exa returns a flat `results[]` and no generated answer, so `content` is omitted. Each result maps to a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← the first non-empty `highlights[]` entry (a result with no highlight has no portable snippet and is dropped), `publishedAt` ← `publishedDate`. The provider passes `maxResults` through as Exa's `numResults` for a cost/latency optimization; the final bound is enforced by the seam. Provider failures (HTTP errors, network failure, unparseable or wrong-shape bodies) surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`. diff --git a/packages/web/web-search-exa/src/provider.ts b/packages/web/web-search-exa/src/provider.ts index 70e14cbf25..3774bd5d58 100644 --- a/packages/web/web-search-exa/src/provider.ts +++ b/packages/web/web-search-exa/src/provider.ts @@ -118,14 +118,14 @@ export class ExaSearchProvider implements WebSearchProvider { throw new WebError(message, 'WEB_PROVIDER_ERROR') } - let payload: ExaSearchResponse try { - payload = await response.json() as ExaSearchResponse + const payload = await response.json() as ExaSearchResponse + return mapExaResponse(request.query, payload) } catch (error: unknown) { if (isAbortError(error)) throw new WebError('Exa search aborted', 'WEB_ABORTED', { cause: error }) - throw new WebError(`Exa returned an unparseable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) + if (error instanceof WebError) throw error + throw new WebError(`Exa returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) } - return mapExaResponse(request.query, payload) } } diff --git a/packages/web/web-search-exa/tests/exa.spec.ts b/packages/web/web-search-exa/tests/exa.spec.ts index 403198401e..436e542d7c 100644 --- a/packages/web/web-search-exa/tests/exa.spec.ts +++ b/packages/web/web-search-exa/tests/exa.spec.ts @@ -60,6 +60,7 @@ describe('Exa result mapping', () => { it('tolerates a missing results array', () => { expect(mapExaResponse('q', {}).sources).toEqual([]) }) + }) describe('ExaSearchProvider status', () => { @@ -148,6 +149,12 @@ describe('ExaSearchProvider error handling', () => { .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) }) + it('maps a well-formed body of the wrong shape to WEB_PROVIDER_ERROR, not a raw TypeError', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ results: {} }, { status: 200 }))) + await expect(new ExaSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) + it('surfaces an abort during success-body parse as WEB_ABORTED, not provider error', async () => { const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: true, status: 200 } vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response)) diff --git a/packages/web/web-search-perplexity/src/provider.ts b/packages/web/web-search-perplexity/src/provider.ts index 69b4f794dd..809086026a 100644 --- a/packages/web/web-search-perplexity/src/provider.ts +++ b/packages/web/web-search-perplexity/src/provider.ts @@ -126,14 +126,14 @@ export class PerplexitySearchProvider implements WebSearchProvider { throw new WebError(message, 'WEB_PROVIDER_ERROR') } - let payload: PerplexityResponse try { - payload = await response.json() as PerplexityResponse + const payload = await response.json() as PerplexityResponse + return mapPerplexityResponse(request.query, payload) } catch (error: unknown) { if (isAbortError(error)) throw new WebError('Perplexity search aborted', 'WEB_ABORTED', { cause: error }) - throw new WebError(`Perplexity returned an unparseable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) + if (error instanceof WebError) throw error + throw new WebError(`Perplexity returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) } - return mapPerplexityResponse(request.query, payload) } } diff --git a/packages/web/web-search-perplexity/tests/perplexity.spec.ts b/packages/web/web-search-perplexity/tests/perplexity.spec.ts index c1f76a63fb..d84c34a328 100644 --- a/packages/web/web-search-perplexity/tests/perplexity.spec.ts +++ b/packages/web/web-search-perplexity/tests/perplexity.spec.ts @@ -116,6 +116,12 @@ describe('PerplexitySearchProvider error handling', () => { .rejects.toThrow(expect.objectContaining({ message: 'bad request' })) }) + it('maps a well-formed body of the wrong shape to WEB_PROVIDER_ERROR, not a raw TypeError', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ search_results: null }, { status: 200 }))) + await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) + it('keeps a status-line message when the error body is not JSON', async () => { vi.stubGlobal('fetch', vi.fn(async () => new Response('upstream error', { status: 503 }))) await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) diff --git a/tsconfig.base.json b/tsconfig.base.json index bc4f13bdd5..f0fdcfe197 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -34,8 +34,6 @@ "@cordisjs/plugin-timer": ["./vendor/timer/src"], "@cordisjs/plugin-hmr": ["./vendor/hmr/src"], "@cordisjs/plugin-logger-console": ["./vendor/logger-console/src"], - "@deepseek-ai/dsh-tool-web/search": ["./packages/web/tool-web/src/search.ts"], - "@deepseek-ai/dsh-tool-web/fetch": ["./packages/web/tool-web/src/fetch.ts"], // One wildcard maps every @deepseek-ai/dsh- to its source. Package // dir names are unique across groups, so first-on-disk-wins resolution is // unambiguous; adding a package under an existing group needs no edit