fix(web-search): harden client response boundary

This commit is contained in:
2026-09-01 03:49:52 +08:00
parent 4a1e5d3121
commit 49de82c486
3 changed files with 355 additions and 16 deletions

View File

@@ -12,7 +12,7 @@ const MAX_REQUEST_BYTES = 98_304;
const MAX_QUERY_LENGTH = 2_000;
const MAX_ANSWER_LENGTH = 16_000;
const MAX_SOURCE_TITLE_LENGTH = 240;
const MAX_SOURCE_URL_LENGTH = 4_096;
const MAX_SOURCE_URL_LENGTH = 2_048;
const MAX_SEARCH_QUERY_LENGTH = 500;
const MAX_RETRY_AFTER_SECONDS = 86_400;
const DECIMAL = /^(?:0|[1-9]\d*)\.\d{2}$/u;
@@ -36,6 +36,17 @@ const KNOWN_ERROR_CODES = new Set([
'web_search_result_invalid',
'web_search_submission_unknown',
]);
const FAILED_ERROR_CODES = new Set([
'plugin_reservation_expired',
'plugin_reservation_unavailable',
'web_search_provider_rejected',
'web_search_rate_limited',
'web_search_request_invalid',
]);
const SUBMISSION_UNKNOWN_ERROR_CODES = new Set([
'web_search_result_invalid',
'web_search_submission_unknown',
]);
type FetchImplementation = typeof fetch;
type AccessTokenGetter = typeof getValidWorksSquareAccessToken;
@@ -163,9 +174,12 @@ function billing(value: unknown): WebSearchServerBillingReceipt {
|| !['reserved', 'dispatched', 'settled', 'released', 'expired', 'pending_review', 'refunded'].includes(status)
|| typeof reservedPoints !== 'string'
|| !DECIMAL.test(reservedPoints)
|| (value.actual_points !== null && value.actual_points !== undefined && actualPoints === undefined)
|| (value.actual_points !== null && value.actual_points !== undefined
&& (actualPoints === undefined || !DECIMAL.test(actualPoints)))
|| (value.usage_amount !== null && value.usage_amount !== undefined && usageAmount === undefined)
|| (usageAmount !== undefined && usageAmount !== 1)
|| (['settled', 'refunded'].includes(status) && actualPoints === undefined)
|| (!['settled', 'refunded'].includes(status) && actualPoints !== undefined)
|| value.unit !== 'search_request') {
throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search billing receipt is invalid');
}
@@ -185,12 +199,35 @@ function source(value: unknown): WebSearchSource {
}
const title = boundedText(value.title, MAX_SOURCE_TITLE_LENGTH);
const url = boundedText(value.url, MAX_SOURCE_URL_LENGTH);
if (!title || !url) {
if (!title || !url || !safeSourceUrl(url)) {
throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search source is invalid');
}
return { title, url };
}
function safeSourceUrl(value: string): boolean {
if (!/^https?:\/\//iu.test(value)) return false;
let parsed: URL;
try {
parsed = new URL(value);
} catch {
return false;
}
if ((parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
|| !parsed.hostname
|| parsed.username.length > 0
|| parsed.password.length > 0) {
return false;
}
const schemeSeparator = value.indexOf('//');
const authorityEnd = value.slice(schemeSeparator + 2).search(/[/?#]/u);
const authority = value.slice(
schemeSeparator + 2,
authorityEnd < 0 ? value.length : schemeSeparator + 2 + authorityEnd,
);
return !authority.includes('@');
}
function read(value: unknown, request: WebSearchRequest): WebSearchRead {
if (!isRecord(value) || !exactKeys(
value,
@@ -218,6 +255,7 @@ function read(value: unknown, request: WebSearchRequest): WebSearchRead {
const parsedErrorCode = value.error_code === undefined || value.error_code === null
? null
: errorCode(value.error_code);
const parsedBilling = billing(value.billing);
if (!executionId || !releaseId || !logicalOperationId || !status
|| releaseId !== request.releaseId || logicalOperationId !== request.logicalOperationId
|| (value.answer !== undefined && value.answer !== null && answer === null)
@@ -225,7 +263,17 @@ function read(value: unknown, request: WebSearchRequest): WebSearchRead {
|| !Array.isArray(rawSearchQueries) || rawSearchQueries.length > 8
|| (value.error_code !== undefined && value.error_code !== null && parsedErrorCode === null)
|| (value.retry_after_seconds !== null && value.retry_after_seconds !== undefined && retryAfter === undefined)
|| (retryAfter !== undefined && (status !== 'failed' || parsedErrorCode !== 'web_search_rate_limited'))) {
|| (retryAfter !== undefined && (status !== 'failed' || parsedErrorCode !== 'web_search_rate_limited'))
|| ((status === 'reserved' && parsedBilling.status !== 'reserved')
|| (status === 'dispatched' && parsedBilling.status !== 'dispatched')
|| (status === 'succeeded' && !['settled', 'refunded'].includes(parsedBilling.status))
|| (status === 'failed' && !['released', 'expired'].includes(parsedBilling.status))
|| (status === 'submission_unknown' && parsedBilling.status !== 'pending_review')
|| (status === 'pending_review' && parsedBilling.status !== 'pending_review'))
|| ((status === 'succeeded' || status === 'pending_review') && parsedBilling.usage_amount !== 1)
|| (status === 'failed' && (parsedErrorCode === null || !FAILED_ERROR_CODES.has(parsedErrorCode)))
|| (status === 'submission_unknown'
&& (parsedErrorCode === null || !SUBMISSION_UNKNOWN_ERROR_CODES.has(parsedErrorCode)))) {
throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response is invalid');
}
const sources = rawSources.map(source);
@@ -234,7 +282,7 @@ function read(value: unknown, request: WebSearchRequest): WebSearchRead {
throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response is invalid');
}
if (status === 'succeeded' || status === 'pending_review') {
if (!answer || (status === 'pending_review' && parsedErrorCode !== null)) {
if (!answer || sources.length === 0 || parsedErrorCode !== null) {
throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search result is incomplete');
}
} else if (answer !== null || sources.length > 0 || searchQueries.length > 0) {
@@ -254,7 +302,7 @@ function read(value: unknown, request: WebSearchRequest): WebSearchRead {
searchQueries: searchQueries as string[],
errorCode: parsedErrorCode,
...(retryAfter === undefined ? {} : { retryAfterSeconds: retryAfter }),
billing: billing(value.billing),
billing: parsedBilling,
};
}
@@ -270,11 +318,39 @@ async function readBoundedJson(response: Response): Promise<unknown> {
await response.body?.cancel().catch(() => undefined);
throw new WebSearchClientError('plugin_backend_response_too_large', 502, false, 'Web Search response exceeds its bound');
}
const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes.byteLength > MAX_JSON_BYTES) {
throw new WebSearchClientError('plugin_backend_response_too_large', 502, false, 'Web Search response exceeds its bound');
if (!response.body) return null;
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let length = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!(value instanceof Uint8Array)) {
throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response is invalid');
}
length += value.byteLength;
if (length > MAX_JSON_BYTES) {
await reader.cancel().catch(() => undefined);
throw new WebSearchClientError(
'plugin_backend_response_too_large',
502,
false,
'Web Search response exceeds its bound',
);
}
chunks.push(new Uint8Array(value));
}
} finally {
reader.releaseLock();
}
if (length === 0) return null;
const bytes = new Uint8Array(length);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
if (bytes.byteLength === 0) return null;
try {
return JSON.parse(Buffer.from(bytes).toString('utf8')) as unknown;
} catch {
@@ -356,15 +432,25 @@ function domainMessage(code: string, status: number): string {
}
async function domainError(response: Response): Promise<WebSearchClientError> {
const retryAfter = response.status === 429 ? retryAfterHeader(response) : undefined;
if (response.status === 429) {
await response.body?.cancel().catch(() => undefined);
return new WebSearchClientError(
'web_search_rate_limited',
429,
false,
domainMessage('web_search_rate_limited', 429),
retryAfter,
);
}
let payload: unknown = null;
try {
payload = await readBoundedJson(response);
} catch (error) {
if (error instanceof WebSearchClientError) return error;
} catch {
// Preserve the HTTP-status fallback when the bounded error body is unusable.
}
const detail = isRecord(payload) && isRecord(payload.detail) ? payload.detail : null;
const code = errorCode(detail?.error_code) ?? (response.status === 429 ? 'web_search_rate_limited' : 'plugin_backend_unavailable');
const retryAfter = response.status === 429 ? retryAfterHeader(response) : undefined;
const code = errorCode(detail?.error_code) ?? 'plugin_backend_unavailable';
const retryable = response.status >= 500 && response.status !== 401;
return new WebSearchClientError(
code,