diff --git a/src/bridge.ts b/src/bridge.ts index 45f046f42..9f11338f7 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -5,6 +5,7 @@ import { encodeReasoningEnvelope, type ReasoningEnvelope } from "./responses/rea import { rememberReasoningForCall } from "./responses/reasoning-replay-cache"; import { resolveStallTimeoutSec } from "./stall-timeout"; import { usageDisplayTotalTokens } from "./usage/totals"; +import { appendSafeWebSearchSource, safeWebSearchSources } from "./web-search/sources"; import { isTranslatorBudgetExceededError, releaseTranslatedEvent, @@ -1073,15 +1074,13 @@ export function bridgeToResponsesSSE( }); currentWebSearch = { itemId: wsItemId2, eventId: event.id, outputIndex }; } - closeCurrentWebSearch(event.status ?? "completed", event.queries, event.sources); + const safeSources = safeWebSearchSources(event.sources ?? []); + closeCurrentWebSearch(event.status ?? "completed", event.queries, safeSources); // Queue this search's sources for the next assistant message (dedup by URL). - if (event.sources) { - const seen = new Set(pendingWebSources.map(s => s.url)); - for (const s of event.sources) { - if (!seen.has(s.url)) { - seen.add(s.url); + if (safeSources.length > 0) { + for (const s of safeSources) { + if (appendSafeWebSearchSource(pendingWebSources, s)) { chargeValue(s, "tool_search_sources"); - pendingWebSources.push(s); } } } @@ -1673,18 +1672,16 @@ function buildResponseJSONWithBudget( if (currentSummaryReasoning) flushSummaryReasoning(); if (currentRawReasoning) flushRawReasoning(); flushToolCall(); + const safeSources = safeWebSearchSources(e.sources ?? []); pushOutput({ type: "web_search_call", id: `ws_${uuid()}`, status: e.status ?? "completed", action: webSearchAction(e.queries), - ...(e.sources && e.sources.length > 0 ? { sources: e.sources } : {}), + ...(safeSources.length > 0 ? { sources: safeSources } : {}), }); - if (e.sources) { - const seen = new Set(pendingWebSources.map(s => s.url)); - for (const s of e.sources) { - if (!seen.has(s.url)) { - seen.add(s.url); + if (safeSources.length > 0) { + for (const s of safeSources) { + if (appendSafeWebSearchSource(pendingWebSources, s)) { budget?.chargeRetained(bytesOf(JSON.stringify(s)), { kind: "tool_search_sources" }); - pendingWebSources.push(s); } } } diff --git a/src/web-search/parse.ts b/src/web-search/parse.ts index 30946ac1e..bab5f8964 100644 --- a/src/web-search/parse.ts +++ b/src/web-search/parse.ts @@ -1,3 +1,5 @@ +import { appendSafeWebSearchSource } from "./sources"; + /** A single web source backing the sidecar's answer. */ export interface WebSearchSource { url: string; @@ -30,8 +32,9 @@ interface OutputItem { /** Push a `url_citation` annotation as a source, de-duplicated by URL. */ function collectAnnotation(ann: AnnotationLike | undefined, sources: WebSearchSource[], seen: Set): void { if (!ann || ann.type !== "url_citation" || typeof ann.url !== "string" || seen.has(ann.url)) return; - seen.add(ann.url); - sources.push({ url: ann.url, ...(ann.title ? { title: ann.title } : {}) }); + if (appendSafeWebSearchSource(sources, { url: ann.url, ...(ann.title !== undefined ? { title: ann.title } : {}) })) { + seen.add(ann.url); + } } /** @@ -101,8 +104,7 @@ function extractTrailingSources(text: string): { text: string; sources: WebSearc const title = cleanTitle(inlinePrefix) || (pendingTitle ? cleanTitle(pendingTitle) : ""); pendingTitle = null; if (seen.has(url)) continue; - seen.add(url); - sources.push(title ? { url, title } : { url }); + if (appendSafeWebSearchSource(sources, title ? { url, title } : { url })) seen.add(url); } if (sources.length === 0) return { text, sources: [] }; // Keep text before the header AND any prose after the consumed source lines. @@ -200,9 +202,8 @@ export async function parseSidecarSSE(response: Response): Promise s.url)); for (const s of acc.streamSources) { - if (!seenMerge.has(s.url)) { seenMerge.add(s.url); sources.push(s); } + appendSafeWebSearchSource(sources, s); } // Hosted web_search usually omits url_citation annotations and lists sources in a trailing // `Sources:` markdown block instead. Pull those out (and strip the block from the answer so the @@ -210,9 +211,7 @@ export async function parseSidecarSSE(response: Response): Promise 0 ? body : (typeof text === "string" ? text : ""); if (!finalText.trim() && acc.error) return { text: "", sources, error: acc.error }; diff --git a/src/web-search/sources.ts b/src/web-search/sources.ts new file mode 100644 index 000000000..7b3f89d57 --- /dev/null +++ b/src/web-search/sources.ts @@ -0,0 +1,52 @@ +export interface SafeWebSearchSource { + url: string; + title?: string; +} + +export const MAX_WEB_SEARCH_SOURCES = 20; +export const MAX_WEB_SEARCH_URL_BYTES = 2_048; +export const MAX_WEB_SEARCH_TITLE_BYTES = 256; +export const MAX_WEB_SEARCH_SOURCE_BYTES = 16_384; + +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/; +const encoder = new TextEncoder(); + +function byteLength(value: string): number { + return encoder.encode(value).byteLength; +} + +/** Add a client-safe citation while enforcing the per-message count and byte budgets. */ +export function appendSafeWebSearchSource(target: SafeWebSearchSource[], source: unknown): boolean { + if (target.length >= MAX_WEB_SEARCH_SOURCES || !source || typeof source !== "object") return false; + const candidate = source as { url?: unknown; title?: unknown }; + if (typeof candidate.url !== "string" + || candidate.url.length === 0 + || CONTROL_CHARACTERS.test(candidate.url) + || byteLength(candidate.url) > MAX_WEB_SEARCH_URL_BYTES + || target.some(existing => existing.url === candidate.url)) return false; + + let parsed: URL; + try { parsed = new URL(candidate.url); } catch { return false; } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false; + + let title: string | undefined; + if (candidate.title !== undefined) { + if (typeof candidate.title !== "string" + || candidate.title.length === 0 + || CONTROL_CHARACTERS.test(candidate.title) + || byteLength(candidate.title) > MAX_WEB_SEARCH_TITLE_BYTES) return false; + title = candidate.title; + } + + const next = title ? { url: candidate.url, title } : { url: candidate.url }; + const usedBytes = target.reduce((sum, item) => sum + byteLength(item.url) + byteLength(item.title ?? ""), 0); + if (usedBytes + byteLength(next.url) + byteLength(next.title ?? "") > MAX_WEB_SEARCH_SOURCE_BYTES) return false; + target.push(next); + return true; +} + +export function safeWebSearchSources(sources: readonly unknown[]): SafeWebSearchSource[] { + const safe: SafeWebSearchSource[] = []; + for (const source of sources) appendSafeWebSearchSource(safe, source); + return safe; +} diff --git a/tests/bridge.test.ts b/tests/bridge.test.ts index 848029252..79cad9735 100644 --- a/tests/bridge.test.ts +++ b/tests/bridge.test.ts @@ -982,6 +982,36 @@ describe("Responses bridge web_search_call native item", () => { type: "url_citation", url: "https://nodejs.org", title: "Node.js", start_index: 0, end_index: 0, }]); }); + + test("unsafe and oversized web-search citations are not relayed to clients", async () => { + const sources = [ + { url: "javascript:alert(1)", title: "unsafe" }, + { url: "data:text/html,unsafe" }, + { url: "https://control.test/path\u0000" }, + { url: "https://title.test", title: "bad\u0001title" }, + { url: "https://oversized.test", title: "x".repeat(257) }, + { url: "https://safe.test/docs", title: "Safe docs" }, + ...Array.from({ length: 25 }, (_, index) => ({ url: `https://safe.test/${index}` })), + ]; + const events: AdapterEvent[] = [ + { type: "web_search_call_end", id: "ws_safe", queries: ["docs"], sources }, + { type: "text_delta", text: "answer" }, + { type: "done" }, + ]; + const frames = await collectSse(bridgeToResponsesSSE(replay(events), "routed/model")); + const done = frames.find(f => f.event === "response.output_item.done" + && (f.data.item as Record)?.type === "message"); + const streamingPart = ((done!.data.item as Record).content as Record[])[0]; + expect((streamingPart.annotations as unknown[]).slice(0, 1)).toEqual([ + { type: "url_citation", url: "https://safe.test/docs", title: "Safe docs", start_index: 0, end_index: 0 }, + ]); + expect(streamingPart.annotations).toHaveLength(20); + + const json = buildResponseJSON(events, "routed/model"); + const message = (json.output as Record[]).find(item => item.type === "message")!; + const batchPart = (message.content as Record[])[0]; + expect(batchPart.annotations).toEqual(streamingPart.annotations); + }); }); describe("Responses bridge stopReason threading (issue #246)", () => {