mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(web): reject credentialed search redirects
This commit is contained in:
@@ -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')
|
||||
|
||||
123
packages/web/web-search-deepseek/tests/redirect.spec.ts
Normal file
123
packages/web/web-search-deepseek/tests/redirect.spec.ts
Normal file
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' }] })
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user