mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge pull request #406 from deepseek-harness/codex/reject-credentialed-search-redirects
fix(web): reject credentialed search redirects
This commit is contained in:
5
packages/web/AGENTS.md
Normal file
5
packages/web/AGENTS.md
Normal 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.
|
||||
@@ -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