Skip to content

Commit 1d74cea

Browse files
committed
fix(exa): bound untrusted search JSON response reads
Exa search success responses were read via an unbounded `await response.json()`, so a misbehaving or hostile endpoint could stream an arbitrarily large body into memory before parsing. Read the success body through the shared bounded reader (16 MiB cap, the same limit other bundled providers use) and cancel the stream on overflow. This mirrors the error-body bound already in place and the #95103/#95108 response -limit campaign on the success-JSON side. AI-assisted.
1 parent a0f93cf commit 1d74cea

2 files changed

Lines changed: 63 additions & 2 deletions

File tree

extensions/exa/src/exa-web-search-provider.runtime.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
wrapWebContent,
2121
writeCachedSearchPayload,
2222
} from "openclaw/plugin-sdk/provider-web-search";
23+
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
2324
import {
2425
normalizeOptionalLowercaseString,
2526
normalizeOptionalString,
@@ -30,6 +31,10 @@ const EXA_SEARCH_TYPES = ["auto", "neural", "fast", "deep", "deep-reasoning", "i
3031
const EXA_FRESHNESS_VALUES = ["day", "week", "month", "year"] as const;
3132
const EXA_MAX_SEARCH_COUNT = 100;
3233
const EXA_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
34+
// Exa search responses are untrusted external bodies. Cap the success JSON the
35+
// same way other bundled providers do (16 MiB) so a misbehaving or hostile
36+
// endpoint cannot stream an unbounded body into memory before we parse it.
37+
const EXA_SEARCH_JSON_MAX_BYTES = 16 * 1024 * 1024;
3338

3439
type ExaConfig = {
3540
apiKey?: string;
@@ -70,9 +75,17 @@ type ExaSearchResponse = {
7075
results?: unknown;
7176
};
7277

73-
async function readExaSearchResults(response: Response): Promise<ExaSearchResult[]> {
78+
async function readExaSearchResults(
79+
response: Response,
80+
opts?: { maxBytes?: number },
81+
): Promise<ExaSearchResult[]> {
82+
const maxBytes = opts?.maxBytes ?? EXA_SEARCH_JSON_MAX_BYTES;
83+
const bytes = await readResponseWithLimit(response, maxBytes, {
84+
onOverflow: ({ maxBytes: maxBytesLocal }) =>
85+
new Error(`Exa API response exceeds ${maxBytesLocal} bytes`),
86+
});
7487
try {
75-
return normalizeExaResults(await response.json());
88+
return normalizeExaResults(JSON.parse(new TextDecoder().decode(bytes)));
7689
} catch (cause) {
7790
throw new Error("Exa API returned malformed JSON", { cause });
7891
}

extensions/exa/src/exa-web-search-provider.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,33 @@ function cancelTrackedResponse(
2626
};
2727
}
2828

29+
function streamingJsonResponse(params: { chunkCount: number; chunkSize: number }): {
30+
response: Response;
31+
getReadCount: () => number;
32+
} {
33+
// Streaming fixture proves an oversized success body stops being read before
34+
// the whole payload is buffered into memory.
35+
let reads = 0;
36+
const encoder = new TextEncoder();
37+
const stream = new ReadableStream<Uint8Array>({
38+
pull(controller) {
39+
if (reads >= params.chunkCount) {
40+
controller.close();
41+
return;
42+
}
43+
reads += 1;
44+
controller.enqueue(encoder.encode("a".repeat(params.chunkSize)));
45+
},
46+
});
47+
return {
48+
response: new Response(stream, {
49+
status: 200,
50+
headers: { "content-type": "application/json" },
51+
}),
52+
getReadCount: () => reads,
53+
};
54+
}
55+
2956
describe("exa web search provider", () => {
3057
it("exposes the expected metadata and selection wiring", () => {
3158
const provider = createExaWebSearchProvider();
@@ -265,6 +292,27 @@ describe("exa web search provider", () => {
265292
);
266293
});
267294

295+
it("parses well-formed Exa search JSON under the byte cap", async () => {
296+
const response = new Response(
297+
JSON.stringify({ results: [{ url: "https://example.com", title: "Example" }] }),
298+
{ status: 200, headers: { "content-type": "application/json" } },
299+
);
300+
301+
await expect(testing.readExaSearchResults(response)).resolves.toEqual([
302+
{ url: "https://example.com", title: "Example" },
303+
]);
304+
});
305+
306+
it("caps oversized Exa search JSON instead of buffering the whole body", async () => {
307+
const streamed = streamingJsonResponse({ chunkCount: 64, chunkSize: 1024 });
308+
309+
await expect(
310+
testing.readExaSearchResults(streamed.response, { maxBytes: 4096 }),
311+
).rejects.toThrow(/Exa API response exceeds 4096 bytes/);
312+
313+
expect(streamed.getReadCount()).toBeLessThan(64);
314+
});
315+
268316
it("bounds Exa API error bodies without using response.text()", async () => {
269317
const tracked = cancelTrackedResponse(`${"exa upstream unavailable ".repeat(1024)}tail`, {
270318
status: 503,

0 commit comments

Comments
 (0)