Просмотр исходного кода

fix(web): reject credentialed search redirects

Tianyi Cui 2 месяцев назад
Родитель
Сommit
5e0e4b2401

+ 1 - 1
packages/web/web-search-deepseek/README.md

@@ -37,7 +37,7 @@ DeepSeek returns no provider-generated answer surface this provider trusts as `c
 
 Results are deduplicated by URL because one request may surface the same page across searches. DeepSeek exposes `maxUses`, not a result-count knob, so the seam enforces `maxResults` by truncating `sources[]` and setting `truncated`.
 
-Provider failures become `WEB_PROVIDER_ERROR`; caller cancellation becomes `WEB_ABORTED`.
+Provider failures become `WEB_PROVIDER_ERROR`; caller cancellation becomes `WEB_ABORTED`. HTTP redirects are rejected before the `Location` target is contacted and surface as `WEB_PROVIDER_ERROR`.
 
 ## Model Experience
 

+ 2 - 1
packages/web/web-search-deepseek/src/provider.ts

@@ -127,7 +127,7 @@ export function mapAnthropicResponse(response: AnthropicResponse): WebSearchResu
   return { sources, truncated: false }
 }
 
-/** The DeepSeek-backed search provider. */
+/** The DeepSeek-backed search provider; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */
 export class DeepSeekSearchProvider implements WebSearchProvider {
   readonly id = DEEPSEEK_PROVIDER_ID
 
@@ -145,6 +145,7 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
     try {
       response = await fetch(`${this.options.baseURL}/messages`, {
         method: 'POST',
+        redirect: 'error',
         headers: {
           // Official DeepSeek expects `x-api-key`; an Anthropic-compatible proxy
           // may expect `Authorization: Bearer` — send both so either resolves.

+ 1 - 0
packages/web/web-search-deepseek/tests/deepseek.spec.ts

@@ -162,6 +162,7 @@ describe('DeepSeekSearchProvider request mapping', () => {
     await new DeepSeekSearchProvider(options).search({ query: 'hello' })
     const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
     expect(url).toBe('https://api.deepseek.test/anthropic/v1/messages')
+    expect(init).toMatchObject({ method: 'POST', redirect: 'error' })
     const headers = init.headers as Record<string, string>
     expect(headers['x-api-key']).toBe('ds-key')
     expect(headers['authorization']).toBe('Bearer ds-key')

+ 123 - 0
packages/web/web-search-deepseek/tests/redirect.spec.ts

@@ -0,0 +1,123 @@
+/**
+ * Real HTTP coverage proves whether native `fetch` contacts a cross-origin `Location`; mocked
+ * request-init assertions alone cannot observe that boundary.
+ */
+
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { createServer, type IncomingMessage, type Server } from 'node:http'
+import type { AddressInfo } from 'node:net'
+import { DeepSeekSearchProvider } from '@deepseek-ai/dsh-web-search-deepseek'
+
+const TEST_API_KEY = 'redirect-test-key'
+const TEST_QUERY = 'private redirect query'
+const targetRequests: ReceivedRequest[] = []
+
+interface ReceivedRequest {
+  readonly body: string
+  readonly headers: IncomingMessage['headers']
+  readonly method?: string
+}
+
+let redirectOrigin: string
+let targetOrigin: string
+
+const targetServer = createServer((request, response) => {
+  void captureRequest(request).then((received) => {
+    targetRequests.push(received)
+    response.writeHead(204).end()
+  }, (error: unknown) => response.destroy(asError(error)))
+})
+
+const redirectServer = createServer((request, response) => {
+  request.resume()
+  const status = Number(new URL(request.url ?? '/', 'http://fixture.test').pathname.split('/')[1])
+  response.writeHead(status, { location: `${targetOrigin}/collect` }).end()
+})
+
+beforeAll(async () => {
+  targetOrigin = await listen(targetServer)
+  redirectOrigin = await listen(redirectServer)
+})
+
+afterAll(async () => {
+  await Promise.all([close(redirectServer), close(targetServer)])
+})
+
+describe('DeepSeekSearchProvider redirect policy', () => {
+  it.each([301, 302, 303, 307, 308])('rejects HTTP %i before contacting Location', async (status) => {
+    targetRequests.length = 0
+    const provider = new DeepSeekSearchProvider({
+      apiKey: TEST_API_KEY,
+      baseURL: `${redirectOrigin}/${status}`,
+      model: 'deepseek-chat',
+      apiVersion: '2023-06-01',
+      maxTokens: 32,
+      maxUses: 1,
+    })
+
+    await expect(provider.search({ query: TEST_QUERY }))
+      .rejects.toMatchObject({ code: 'WEB_PROVIDER_ERROR' })
+    expect(targetRequests).toHaveLength(0)
+  })
+
+  it('shows default 307 following forwards the custom credential and POST body', async () => {
+    targetRequests.length = 0
+    const body = JSON.stringify({ query: TEST_QUERY })
+    await fetch(`${redirectOrigin}/307`, {
+      method: 'POST',
+      headers: {
+        'x-api-key': TEST_API_KEY,
+        'authorization': `Bearer ${TEST_API_KEY}`,
+        'content-type': 'application/json',
+      },
+      body,
+    })
+
+    expect(targetRequests).toHaveLength(1)
+    expect(targetRequests[0]).toMatchObject({ method: 'POST', body })
+    expect(targetRequests[0]?.headers['x-api-key']).toBe(TEST_API_KEY)
+  })
+})
+
+/** Read a complete request received by the redirect target. */
+function captureRequest(request: IncomingMessage): Promise<ReceivedRequest> {
+  return new Promise((resolve, reject) => {
+    const chunks: Uint8Array[] = []
+    request.on('data', (chunk: unknown) => {
+      if (typeof chunk === 'string' || chunk instanceof Uint8Array) chunks.push(Buffer.from(chunk))
+      else reject(new TypeError('unexpected HTTP request chunk'))
+    })
+    request.once('error', reject)
+    request.once('end', () => {
+      resolve({
+        ...request.method !== undefined ? { method: request.method } : {},
+        headers: request.headers,
+        body: Buffer.concat(chunks).toString('utf8'),
+      })
+    })
+  })
+}
+
+/** Listen on an ephemeral loopback port and return the server origin. */
+async function listen(server: Server): Promise<string> {
+  await new Promise<void>((resolve, reject) => {
+    server.once('error', reject)
+    server.listen(0, '127.0.0.1', resolve)
+  })
+  const address = server.address() as AddressInfo
+  return `http://127.0.0.1:${address.port}`
+}
+
+/** Close a listening fixture server after every request has settled. */
+async function close(server: Server): Promise<void> {
+  if (!server.listening) return
+  await new Promise<void>((resolve, reject) => server.close((error) => {
+    if (error === undefined) resolve()
+    else reject(error)
+  }))
+}
+
+/** Normalize an unknown fixture failure for `ServerResponse.destroy`. */
+function asError(error: unknown): Error {
+  return error instanceof Error ? error : new Error(String(error))
+}

+ 1 - 1
packages/web/web-search-exa/README.md

@@ -23,7 +23,7 @@ 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`. A request's `maxResults` wins over the configured `numResults` default and is sent 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`.
+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`. A request's `maxResults` wins over the configured `numResults` default and is sent 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`. HTTP redirects are rejected before the `Location` target is contacted and surface as `WEB_PROVIDER_ERROR`.
 
 ## Model Experience
 

+ 2 - 1
packages/web/web-search-exa/src/provider.ts

@@ -80,7 +80,7 @@ export function mapExaResponse(response: ExaSearchResponse): WebSearchResult {
   return { sources, truncated: false }
 }
 
-/** The Exa-backed search provider. */
+/** The Exa-backed search provider; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */
 export class ExaSearchProvider implements WebSearchProvider {
   readonly id = EXA_PROVIDER_ID
 
@@ -100,6 +100,7 @@ export class ExaSearchProvider implements WebSearchProvider {
     try {
       response = await fetch(`${this.options.baseURL}/search`, {
         method: 'POST',
+        redirect: 'error',
         headers: {
           'authorization': `Bearer ${this.options.apiKey}`,
           'content-type': 'application/json',

+ 1 - 0
packages/web/web-search-exa/tests/exa.spec.ts

@@ -96,6 +96,7 @@ describe('ExaSearchProvider request mapping', () => {
     expect(fetchMock).toHaveBeenCalledOnce()
     const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
     expect(url).toBe('https://api.exa.test/search')
+    expect(init).toMatchObject({ method: 'POST', redirect: 'error' })
     expect((init.headers as Record<string, string>)['authorization']).toBe('Bearer exa-key')
     expect(JSON.parse(init.body as string)).toEqual({
       query: 'hello',

+ 1 - 1
packages/web/web-search-perplexity/README.md

@@ -23,7 +23,7 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i
 
 ## Mapping
 
-`content` ← `choices[0].message.content` (the generated answer). `sources[]` prefers the structured `search_results[]` (`url`, `title`, `snippet`, `publishedAt` ← `date`), falling back to the URL-only `citations[]` array only when `search_results` is absent — those sources carry just a `url`, which is why `title`/`snippet`/`publishedAt` are optional on the seam. Provider failures surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`. Perplexity has no result-count control, so `maxResults` is enforced by the seam (truncating `sources[]` and setting `truncated`).
+`content` ← `choices[0].message.content` (the generated answer). `sources[]` prefers the structured `search_results[]` (`url`, `title`, `snippet`, `publishedAt` ← `date`), falling back to the URL-only `citations[]` array only when `search_results` is absent — those sources carry just a `url`, which is why `title`/`snippet`/`publishedAt` are optional on the seam. Provider failures surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`. HTTP redirects are rejected before the `Location` target is contacted and surface as `WEB_PROVIDER_ERROR`. Perplexity has no result-count control, so `maxResults` is enforced by the seam (truncating `sources[]` and setting `truncated`).
 
 ## Model Experience
 

+ 2 - 1
packages/web/web-search-perplexity/src/provider.ts

@@ -82,7 +82,7 @@ export function mapPerplexityResponse(response: PerplexityResponse): WebSearchRe
   }
 }
 
-/** The Perplexity-backed search provider. */
+/** The Perplexity-backed search provider; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */
 export class PerplexitySearchProvider implements WebSearchProvider {
   readonly id = PERPLEXITY_PROVIDER_ID
 
@@ -103,6 +103,7 @@ export class PerplexitySearchProvider implements WebSearchProvider {
     try {
       response = await fetch(`${this.options.baseURL}/chat/completions`, {
         method: 'POST',
+        redirect: 'error',
         headers: {
           'authorization': `Bearer ${this.options.apiKey}`,
           'content-type': 'application/json',

+ 1 - 0
packages/web/web-search-perplexity/tests/perplexity.spec.ts

@@ -90,6 +90,7 @@ describe('PerplexitySearchProvider request mapping', () => {
     await new PerplexitySearchProvider(options).search({ query: 'hello' })
     const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
     expect(url).toBe('https://api.perplexity.test/chat/completions')
+    expect(init).toMatchObject({ method: 'POST', redirect: 'error' })
     expect((init.headers as Record<string, string>)['authorization']).toBe('Bearer pplx-key')
     expect(JSON.parse(init.body as string)).toEqual({ model: 'sonar', max_tokens: 1024, messages: [{ role: 'user', content: 'hello' }] })
   })