fix(web): reject credentialed search redirects

This commit is contained in:
Tianyi Cui
2026-07-19 21:32:23 +08:00
parent f84092045c
commit 5e0e4b2401
10 changed files with 135 additions and 6 deletions
+1 -1
View File
@@ -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
@@ -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.
@@ -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')
@@ -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
View File
@@ -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
View File
@@ -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',
@@ -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
View File
@@ -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
@@ -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',
@@ -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' }] })
})