-
Notifications
You must be signed in to change notification settings - Fork 0
[WRONG BRANCH] web-search: validate and sanitize citation sources before relaying #183
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
| } | ||
|
Comment on lines
+1681
to
1685
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Release batch pending-source budget after annotation transfer. Line 1684 charges each accepted source as Repeated web-search and message cycles can exhaust the translator budget even when the final batch response fits. Release the pending-source bytes when 🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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; | ||||||||||||||||||||||
|
Comment on lines
+28
to
+30
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Reject credentials in citation URLs. Lines 28-30 accept Reject URLs when Proposed fix- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
+ if (
+ (parsed.protocol !== "http:" && parsed.protocol !== "https:")
+ || parsed.username
+ || parsed.password
+ ) return false;📝 Committable suggestion
Suggested change
🤖 Prompt for AI AgentsSource: Path instructions |
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| 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; | ||||||||||||||||||||||
|
Comment on lines
+42
to
+43
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an untrusted URL or title contains JSON-escaped characters such as quotes or backslashes, this sums the raw UTF-8 strings even though both bridge paths relay their JSON serialization. For example, eight accepted HTTPS URLs containing repeated quotes total 16,128 bytes here but serialize to 32,217 bytes, bypassing the intended 16 KiB aggregate cap. Compute the budget from the serialized representation, including citation wrapper overhead, or retain and measure a normalized URL. Useful? React with 👍 / 👎. |
||||||||||||||||||||||
| 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; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<string, unknown>)?.type === "message"); | ||
| const streamingPart = ((done!.data.item as Record<string, unknown>).content as Record<string, unknown>[])[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<string, unknown>[]).find(item => item.type === "message")!; | ||
| const batchPart = (message.content as Record<string, unknown>[])[0]; | ||
| expect(batchPart.annotations).toEqual(streamingPart.annotations); | ||
|
Comment on lines
+1001
to
+1013
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win Assert sanitized sources on The test only checks assistant-message annotations. If 🤖 Prompt for AI AgentsSource: Path instructions |
||
| }); | ||
| }); | ||
|
|
||
| describe("Responses bridge stopReason threading (issue #246)", () => { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Wrap this switch case in a block.
Line 1675 declares
safeSourcesdirectly in aswitchcase. Biome reportslint/correctness/noSwitchDeclarationsfor this declaration.Wrap the
web_search_call_endcase body in braces so the lint check passes.🧰 Tools
🪛 Biome (2.5.6)
[error] 1675-1675: Other switch clauses can erroneously access this declaration.
Wrap the declaration in a block to restrict its access to the switch clause.
(lint/correctness/noSwitchDeclarations)
🤖 Prompt for AI Agents
Source: Linters/SAST tools