Skip to content

Commit b7b2e1f

Browse files
authored
fix(pdf): guard native provider requests (#97872)
* fix(pdf): guard native provider requests * fix(pdf): preserve configured origin trust * fix(pdf): preserve resolver compatibility
1 parent 3047e6c commit b7b2e1f

6 files changed

Lines changed: 339 additions & 117 deletions

File tree

src/agents/provider-transport-fetch.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -743,13 +743,13 @@ function canApplyFakeIpHostnamePolicy(value: unknown): value is string {
743743
);
744744
}
745745

746-
function resolveModelTransportSsrFPolicy(params: {
747-
model: Model;
746+
export function resolveProviderTransportSsrFPolicy(params: {
747+
baseUrl?: string;
748748
url: string;
749749
allowPrivateNetwork?: boolean;
750750
trustConfiguredBaseUrlOrigin?: boolean;
751751
}): SsrFPolicy | undefined {
752-
const baseUrl = (params.model as { baseUrl?: unknown }).baseUrl;
752+
const baseUrl = params.baseUrl;
753753
const baseOrigin = resolveHttpOrigin(baseUrl);
754754
const requestOrigin = resolveHttpOrigin(params.url);
755755
const requestMatchesBaseOrigin =
@@ -811,8 +811,8 @@ export function buildGuardedModelFetch(
811811
: (() => {
812812
throw new Error("Unsupported fetch input for transport-aware model request");
813813
})());
814-
const policy = resolveModelTransportSsrFPolicy({
815-
model,
814+
const policy = resolveProviderTransportSsrFPolicy({
815+
baseUrl: model.baseUrl,
816816
url,
817817
allowPrivateNetwork: requestConfig.allowPrivateNetwork,
818818
// Only operator-configured custom/local endpoints get exact-origin trust;

src/agents/tools/pdf-native-providers.test.ts

Lines changed: 136 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ function makeAnthropicAnalyzeParams(
1818
pdfs: Array<{ base64: string; filename: string }>;
1919
maxTokens: number;
2020
baseUrl: string;
21+
requestConfig: Parameters<typeof pdfNativeProviders.anthropicAnalyzePdf>[0]["requestConfig"];
2122
}> = {},
2223
) {
2324
return {
@@ -50,7 +51,16 @@ function makeGeminiAnalyzeParams(
5051
describe("native PDF provider API calls", () => {
5152
const priorFetch = global.fetch;
5253

53-
const mockFetchResponse = (response: unknown) => {
54+
const jsonResponse = (payload: unknown, init?: ResponseInit): Response =>
55+
new Response(JSON.stringify(payload), {
56+
status: 200,
57+
headers: { "Content-Type": "application/json" },
58+
...init,
59+
});
60+
61+
const textResponse = (body: string, init?: ResponseInit): Response => new Response(body, init);
62+
63+
const mockFetchResponse = (response: Response) => {
5464
const fetchMock = vi.fn().mockResolvedValue(response);
5565
global.fetch = Object.assign(fetchMock, { preconnect: vi.fn() }) as typeof global.fetch;
5666
return fetchMock;
@@ -70,12 +80,11 @@ describe("native PDF provider API calls", () => {
7080
});
7181

7282
it("anthropicAnalyzePdf sends correct request shape", async () => {
73-
const fetchMock = mockFetchResponse({
74-
ok: true,
75-
json: async () => ({
83+
const fetchMock = mockFetchResponse(
84+
jsonResponse({
7685
content: [{ type: "text", text: "Analysis of PDF" }],
7786
}),
78-
});
87+
);
7988

8089
const result = await pdfNativeProviders.anthropicAnalyzePdf(
8190
makeAnthropicAnalyzeParams({
@@ -89,9 +98,10 @@ describe("native PDF provider API calls", () => {
8998
expect(fetchMock).toHaveBeenCalledTimes(1);
9099
const [url, opts] = firstFetchCall(fetchMock) as [
91100
string,
92-
{ body: string; signal: AbortSignal },
101+
{ body: string; headers: Headers; signal: AbortSignal },
93102
];
94103
expect(url).toContain("/v1/messages");
104+
expect(opts.headers.get("x-api-key")).toBe("test-key");
95105
expect(opts.signal).toBeInstanceOf(AbortSignal);
96106
expect(opts.signal.aborted).toBe(false);
97107
const body = JSON.parse(opts.body);
@@ -104,12 +114,11 @@ describe("native PDF provider API calls", () => {
104114

105115
it("anthropicAnalyzePdf honors ANTHROPIC_BASE_URL when no base URL is configured", async () => {
106116
vi.stubEnv("ANTHROPIC_BASE_URL", "https://anthropic-pdf-proxy.example/v1");
107-
const fetchMock = mockFetchResponse({
108-
ok: true,
109-
json: async () => ({
117+
const fetchMock = mockFetchResponse(
118+
jsonResponse({
110119
content: [{ type: "text", text: "Analysis of PDF" }],
111120
}),
112-
});
121+
);
113122

114123
await pdfNativeProviders.anthropicAnalyzePdf(makeAnthropicAnalyzeParams());
115124

@@ -118,12 +127,7 @@ describe("native PDF provider API calls", () => {
118127
});
119128

120129
it("anthropicAnalyzePdf throws on API error", async () => {
121-
mockFetchResponse({
122-
ok: false,
123-
status: 400,
124-
statusText: "Bad Request",
125-
text: async () => "invalid request",
126-
});
130+
mockFetchResponse(textResponse("invalid request", { status: 400, statusText: "Bad Request" }));
127131

128132
await expect(
129133
pdfNativeProviders.anthropicAnalyzePdf(makeAnthropicAnalyzeParams()),
@@ -196,27 +200,117 @@ describe("native PDF provider API calls", () => {
196200
});
197201

198202
it("anthropicAnalyzePdf throws when response has no text", async () => {
199-
mockFetchResponse({
200-
ok: true,
201-
json: async () => ({
203+
mockFetchResponse(
204+
jsonResponse({
202205
content: [{ type: "text", text: " " }],
203206
}),
204-
});
207+
);
205208

206209
await expect(
207210
pdfNativeProviders.anthropicAnalyzePdf(makeAnthropicAnalyzeParams()),
208211
).rejects.toThrow("Anthropic PDF returned no text");
209212
});
210213

214+
it("anthropicAnalyzePdf trusts the exact configured local provider origin", async () => {
215+
const fetchMock = mockFetchResponse(
216+
jsonResponse({
217+
content: [{ type: "text", text: "ok" }],
218+
}),
219+
);
220+
221+
await expect(
222+
pdfNativeProviders.anthropicAnalyzePdf(
223+
makeAnthropicAnalyzeParams({ baseUrl: "http://127.0.0.1:11434" }),
224+
),
225+
).resolves.toBe("ok");
226+
expect(fetchMock).toHaveBeenCalledTimes(1);
227+
});
228+
229+
it("anthropicAnalyzePdf honors explicit private-network denial for a configured local origin", async () => {
230+
const fetchMock = mockFetchResponse(
231+
jsonResponse({
232+
content: [{ type: "text", text: "ok" }],
233+
}),
234+
);
235+
236+
await expect(
237+
pdfNativeProviders.anthropicAnalyzePdf(
238+
makeAnthropicAnalyzeParams({
239+
baseUrl: "http://127.0.0.1:11434",
240+
requestConfig: {
241+
request: { allowPrivateNetwork: false },
242+
},
243+
}),
244+
),
245+
).rejects.toThrow(/private|SSRF|blocked/i);
246+
expect(fetchMock).not.toHaveBeenCalled();
247+
});
248+
249+
it("anthropicAnalyzePdf does not carry exact-origin trust across redirects", async () => {
250+
const fetchMock = mockFetchResponse(
251+
new Response(null, {
252+
status: 302,
253+
headers: { location: "http://127.0.0.1:4321/v1/messages" },
254+
}),
255+
);
256+
257+
await expect(
258+
pdfNativeProviders.anthropicAnalyzePdf(
259+
makeAnthropicAnalyzeParams({ baseUrl: "http://127.0.0.1:11434" }),
260+
),
261+
).rejects.toThrow(/private|SSRF|blocked/i);
262+
expect(fetchMock).toHaveBeenCalledTimes(1);
263+
});
264+
265+
it("anthropicAnalyzePdf allows off-origin private redirects with explicit opt-in", async () => {
266+
const fetchMock = vi
267+
.fn()
268+
.mockResolvedValueOnce(
269+
new Response(null, {
270+
status: 302,
271+
headers: { location: "http://127.0.0.1:4321/v1/messages" },
272+
}),
273+
)
274+
.mockResolvedValueOnce(
275+
jsonResponse({
276+
content: [{ type: "text", text: "ok" }],
277+
}),
278+
);
279+
global.fetch = Object.assign(fetchMock, { preconnect: vi.fn() }) as typeof global.fetch;
280+
281+
await expect(
282+
pdfNativeProviders.anthropicAnalyzePdf(
283+
makeAnthropicAnalyzeParams({
284+
baseUrl: "http://127.0.0.1:11434",
285+
requestConfig: {
286+
request: { allowPrivateNetwork: true },
287+
},
288+
}),
289+
),
290+
).resolves.toBe("ok");
291+
expect(fetchMock).toHaveBeenCalledTimes(2);
292+
});
293+
294+
it("anthropicAnalyzePdf rejects oversized successful JSON responses", async () => {
295+
mockFetchResponse(
296+
jsonResponse({
297+
content: [{ type: "text", text: "x".repeat(17 * 1024 * 1024) }],
298+
}),
299+
);
300+
301+
await expect(
302+
pdfNativeProviders.anthropicAnalyzePdf(makeAnthropicAnalyzeParams()),
303+
).rejects.toThrow("JSON response exceeds");
304+
});
305+
211306
it("geminiAnalyzePdf sends correct request shape", async () => {
212307
// Gemini API keys belong in headers here, not query strings that are more
213308
// likely to leak through logs and URL diagnostics.
214-
const fetchMock = mockFetchResponse({
215-
ok: true,
216-
json: async () => ({
309+
const fetchMock = mockFetchResponse(
310+
jsonResponse({
217311
candidates: [{ content: { parts: [{ text: "Gemini PDF analysis" }] } }],
218312
}),
219-
});
313+
);
220314

221315
const result = await pdfNativeProviders.geminiAnalyzePdf(
222316
makeGeminiAnalyzeParams({
@@ -229,12 +323,12 @@ describe("native PDF provider API calls", () => {
229323
expect(fetchMock).toHaveBeenCalledTimes(1);
230324
const [url, opts] = firstFetchCall(fetchMock) as [
231325
string,
232-
{ body: string; headers: Record<string, string>; signal: AbortSignal },
326+
{ body: string; headers: Headers; signal: AbortSignal },
233327
];
234328
expect(url).toContain("generateContent");
235329
expect(url).toContain("gemini-2.5-pro");
236330
expect(url).not.toContain("?key=");
237-
expect(opts.headers["x-goog-api-key"]).toBe("test-key");
331+
expect(opts.headers.get("x-goog-api-key")).toBe("test-key");
238332
expect(opts.signal).toBeInstanceOf(AbortSignal);
239333
expect(opts.signal.aborted).toBe(false);
240334
const body = JSON.parse(opts.body);
@@ -244,36 +338,29 @@ describe("native PDF provider API calls", () => {
244338
});
245339

246340
it("geminiAnalyzePdf throws on API error", async () => {
247-
mockFetchResponse({
248-
ok: false,
249-
status: 500,
250-
statusText: "Internal Server Error",
251-
text: async () => "server error",
252-
});
341+
mockFetchResponse(
342+
textResponse("server error", { status: 500, statusText: "Internal Server Error" }),
343+
);
253344

254345
await expect(pdfNativeProviders.geminiAnalyzePdf(makeGeminiAnalyzeParams())).rejects.toThrow(
255346
"Gemini PDF request failed",
256347
);
257348
});
258349

259350
it("geminiAnalyzePdf throws when no candidates returned", async () => {
260-
mockFetchResponse({
261-
ok: true,
262-
json: async () => ({ candidates: [] }),
263-
});
351+
mockFetchResponse(jsonResponse({ candidates: [] }));
264352

265353
await expect(pdfNativeProviders.geminiAnalyzePdf(makeGeminiAnalyzeParams())).rejects.toThrow(
266354
"Gemini PDF returned no candidates",
267355
);
268356
});
269357

270358
it("anthropicAnalyzePdf supports multiple PDFs", async () => {
271-
const fetchMock = mockFetchResponse({
272-
ok: true,
273-
json: async () => ({
359+
const fetchMock = mockFetchResponse(
360+
jsonResponse({
274361
content: [{ type: "text", text: "Multi-doc analysis" }],
275362
}),
276-
});
363+
);
277364

278365
await pdfNativeProviders.anthropicAnalyzePdf(
279366
makeAnthropicAnalyzeParams({
@@ -295,12 +382,11 @@ describe("native PDF provider API calls", () => {
295382
});
296383

297384
it("anthropicAnalyzePdf uses custom base URL", async () => {
298-
const fetchMock = mockFetchResponse({
299-
ok: true,
300-
json: async () => ({
385+
const fetchMock = mockFetchResponse(
386+
jsonResponse({
301387
content: [{ type: "text", text: "ok" }],
302388
}),
303-
});
389+
);
304390

305391
await pdfNativeProviders.anthropicAnalyzePdf(
306392
makeAnthropicAnalyzeParams({ baseUrl: "https://custom.example.com" }),
@@ -322,12 +408,11 @@ describe("native PDF provider API calls", () => {
322408
});
323409

324410
it("geminiAnalyzePdf does not duplicate /v1beta when baseUrl already includes it", async () => {
325-
const fetchMock = mockFetchResponse({
326-
ok: true,
327-
json: async () => ({
411+
const fetchMock = mockFetchResponse(
412+
jsonResponse({
328413
candidates: [{ content: { parts: [{ text: "ok" }] } }],
329414
}),
330-
});
415+
);
331416

332417
await pdfNativeProviders.geminiAnalyzePdf(
333418
makeGeminiAnalyzeParams({
@@ -341,12 +426,11 @@ describe("native PDF provider API calls", () => {
341426
});
342427

343428
it("geminiAnalyzePdf normalizes bare Google API hosts to a single /v1beta root", async () => {
344-
const fetchMock = mockFetchResponse({
345-
ok: true,
346-
json: async () => ({
429+
const fetchMock = mockFetchResponse(
430+
jsonResponse({
347431
candidates: [{ content: { parts: [{ text: "ok" }] } }],
348432
}),
349-
});
433+
);
350434

351435
await pdfNativeProviders.geminiAnalyzePdf(
352436
makeGeminiAnalyzeParams({

0 commit comments

Comments
 (0)