Merge pull request #406 from deepseek-harness/codex/reject-credentialed-search-redirects

fix(web): reject credentialed search redirects
This commit is contained in:
Tianyi Cui
2026-07-20 18:35:22 +08:00
committed by GitHub
11 changed files with 140 additions and 6 deletions

5
packages/web/AGENTS.md Normal file
View File

@@ -0,0 +1,5 @@
# AGENTS.md — Web Packages
These rules supplement the package conventions in [packages/AGENTS.md](../AGENTS.md).
- **Reject redirects on credential-bearing provider requests.** Configure the HTTP client to fail before following any redirect response. Regression coverage must prove that the redirect target is not contacted and that every credentialed provider opts into the policy. The configured endpoint necessarily receives the initial request; this prevents automatic forwarding of credentials or request data to another origin, not compromise of the configured endpoint.

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

View File

@@ -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.

View File

@@ -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')

View 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))
}

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

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',

View File

@@ -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',

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

View File

@@ -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',

View File

@@ -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' }] })
})