diff --git a/src/agents/provider-transport-fetch.ts b/src/agents/provider-transport-fetch.ts index bcc1ecc828dc..5f1c8e8c4e15 100644 --- a/src/agents/provider-transport-fetch.ts +++ b/src/agents/provider-transport-fetch.ts @@ -743,13 +743,13 @@ function canApplyFakeIpHostnamePolicy(value: unknown): value is string { ); } -function resolveModelTransportSsrFPolicy(params: { - model: Model; +export function resolveProviderTransportSsrFPolicy(params: { + baseUrl?: string; url: string; allowPrivateNetwork?: boolean; trustConfiguredBaseUrlOrigin?: boolean; }): SsrFPolicy | undefined { - const baseUrl = (params.model as { baseUrl?: unknown }).baseUrl; + const baseUrl = params.baseUrl; const baseOrigin = resolveHttpOrigin(baseUrl); const requestOrigin = resolveHttpOrigin(params.url); const requestMatchesBaseOrigin = @@ -811,8 +811,8 @@ export function buildGuardedModelFetch( : (() => { throw new Error("Unsupported fetch input for transport-aware model request"); })()); - const policy = resolveModelTransportSsrFPolicy({ - model, + const policy = resolveProviderTransportSsrFPolicy({ + baseUrl: model.baseUrl, url, allowPrivateNetwork: requestConfig.allowPrivateNetwork, // Only operator-configured custom/local endpoints get exact-origin trust; diff --git a/src/agents/tools/pdf-native-providers.test.ts b/src/agents/tools/pdf-native-providers.test.ts index 10b7614d9b79..eebafe5e70a0 100644 --- a/src/agents/tools/pdf-native-providers.test.ts +++ b/src/agents/tools/pdf-native-providers.test.ts @@ -18,6 +18,7 @@ function makeAnthropicAnalyzeParams( pdfs: Array<{ base64: string; filename: string }>; maxTokens: number; baseUrl: string; + requestConfig: Parameters[0]["requestConfig"]; }> = {}, ) { return { @@ -50,7 +51,16 @@ function makeGeminiAnalyzeParams( describe("native PDF provider API calls", () => { const priorFetch = global.fetch; - const mockFetchResponse = (response: unknown) => { + const jsonResponse = (payload: unknown, init?: ResponseInit): Response => + new Response(JSON.stringify(payload), { + status: 200, + headers: { "Content-Type": "application/json" }, + ...init, + }); + + const textResponse = (body: string, init?: ResponseInit): Response => new Response(body, init); + + const mockFetchResponse = (response: Response) => { const fetchMock = vi.fn().mockResolvedValue(response); global.fetch = Object.assign(fetchMock, { preconnect: vi.fn() }) as typeof global.fetch; return fetchMock; @@ -70,12 +80,11 @@ describe("native PDF provider API calls", () => { }); it("anthropicAnalyzePdf sends correct request shape", async () => { - const fetchMock = mockFetchResponse({ - ok: true, - json: async () => ({ + const fetchMock = mockFetchResponse( + jsonResponse({ content: [{ type: "text", text: "Analysis of PDF" }], }), - }); + ); const result = await pdfNativeProviders.anthropicAnalyzePdf( makeAnthropicAnalyzeParams({ @@ -89,9 +98,10 @@ describe("native PDF provider API calls", () => { expect(fetchMock).toHaveBeenCalledTimes(1); const [url, opts] = firstFetchCall(fetchMock) as [ string, - { body: string; signal: AbortSignal }, + { body: string; headers: Headers; signal: AbortSignal }, ]; expect(url).toContain("/v1/messages"); + expect(opts.headers.get("x-api-key")).toBe("test-key"); expect(opts.signal).toBeInstanceOf(AbortSignal); expect(opts.signal.aborted).toBe(false); const body = JSON.parse(opts.body); @@ -104,12 +114,11 @@ describe("native PDF provider API calls", () => { it("anthropicAnalyzePdf honors ANTHROPIC_BASE_URL when no base URL is configured", async () => { vi.stubEnv("ANTHROPIC_BASE_URL", "https://anthropic-pdf-proxy.example/v1"); - const fetchMock = mockFetchResponse({ - ok: true, - json: async () => ({ + const fetchMock = mockFetchResponse( + jsonResponse({ content: [{ type: "text", text: "Analysis of PDF" }], }), - }); + ); await pdfNativeProviders.anthropicAnalyzePdf(makeAnthropicAnalyzeParams()); @@ -118,12 +127,7 @@ describe("native PDF provider API calls", () => { }); it("anthropicAnalyzePdf throws on API error", async () => { - mockFetchResponse({ - ok: false, - status: 400, - statusText: "Bad Request", - text: async () => "invalid request", - }); + mockFetchResponse(textResponse("invalid request", { status: 400, statusText: "Bad Request" })); await expect( pdfNativeProviders.anthropicAnalyzePdf(makeAnthropicAnalyzeParams()), @@ -196,27 +200,117 @@ describe("native PDF provider API calls", () => { }); it("anthropicAnalyzePdf throws when response has no text", async () => { - mockFetchResponse({ - ok: true, - json: async () => ({ + mockFetchResponse( + jsonResponse({ content: [{ type: "text", text: " " }], }), - }); + ); await expect( pdfNativeProviders.anthropicAnalyzePdf(makeAnthropicAnalyzeParams()), ).rejects.toThrow("Anthropic PDF returned no text"); }); + it("anthropicAnalyzePdf trusts the exact configured local provider origin", async () => { + const fetchMock = mockFetchResponse( + jsonResponse({ + content: [{ type: "text", text: "ok" }], + }), + ); + + await expect( + pdfNativeProviders.anthropicAnalyzePdf( + makeAnthropicAnalyzeParams({ baseUrl: "http://127.0.0.1:11434" }), + ), + ).resolves.toBe("ok"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("anthropicAnalyzePdf honors explicit private-network denial for a configured local origin", async () => { + const fetchMock = mockFetchResponse( + jsonResponse({ + content: [{ type: "text", text: "ok" }], + }), + ); + + await expect( + pdfNativeProviders.anthropicAnalyzePdf( + makeAnthropicAnalyzeParams({ + baseUrl: "http://127.0.0.1:11434", + requestConfig: { + request: { allowPrivateNetwork: false }, + }, + }), + ), + ).rejects.toThrow(/private|SSRF|blocked/i); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("anthropicAnalyzePdf does not carry exact-origin trust across redirects", async () => { + const fetchMock = mockFetchResponse( + new Response(null, { + status: 302, + headers: { location: "http://127.0.0.1:4321/v1/messages" }, + }), + ); + + await expect( + pdfNativeProviders.anthropicAnalyzePdf( + makeAnthropicAnalyzeParams({ baseUrl: "http://127.0.0.1:11434" }), + ), + ).rejects.toThrow(/private|SSRF|blocked/i); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("anthropicAnalyzePdf allows off-origin private redirects with explicit opt-in", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(null, { + status: 302, + headers: { location: "http://127.0.0.1:4321/v1/messages" }, + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + content: [{ type: "text", text: "ok" }], + }), + ); + global.fetch = Object.assign(fetchMock, { preconnect: vi.fn() }) as typeof global.fetch; + + await expect( + pdfNativeProviders.anthropicAnalyzePdf( + makeAnthropicAnalyzeParams({ + baseUrl: "http://127.0.0.1:11434", + requestConfig: { + request: { allowPrivateNetwork: true }, + }, + }), + ), + ).resolves.toBe("ok"); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("anthropicAnalyzePdf rejects oversized successful JSON responses", async () => { + mockFetchResponse( + jsonResponse({ + content: [{ type: "text", text: "x".repeat(17 * 1024 * 1024) }], + }), + ); + + await expect( + pdfNativeProviders.anthropicAnalyzePdf(makeAnthropicAnalyzeParams()), + ).rejects.toThrow("JSON response exceeds"); + }); + it("geminiAnalyzePdf sends correct request shape", async () => { // Gemini API keys belong in headers here, not query strings that are more // likely to leak through logs and URL diagnostics. - const fetchMock = mockFetchResponse({ - ok: true, - json: async () => ({ + const fetchMock = mockFetchResponse( + jsonResponse({ candidates: [{ content: { parts: [{ text: "Gemini PDF analysis" }] } }], }), - }); + ); const result = await pdfNativeProviders.geminiAnalyzePdf( makeGeminiAnalyzeParams({ @@ -229,12 +323,12 @@ describe("native PDF provider API calls", () => { expect(fetchMock).toHaveBeenCalledTimes(1); const [url, opts] = firstFetchCall(fetchMock) as [ string, - { body: string; headers: Record; signal: AbortSignal }, + { body: string; headers: Headers; signal: AbortSignal }, ]; expect(url).toContain("generateContent"); expect(url).toContain("gemini-2.5-pro"); expect(url).not.toContain("?key="); - expect(opts.headers["x-goog-api-key"]).toBe("test-key"); + expect(opts.headers.get("x-goog-api-key")).toBe("test-key"); expect(opts.signal).toBeInstanceOf(AbortSignal); expect(opts.signal.aborted).toBe(false); const body = JSON.parse(opts.body); @@ -244,12 +338,9 @@ describe("native PDF provider API calls", () => { }); it("geminiAnalyzePdf throws on API error", async () => { - mockFetchResponse({ - ok: false, - status: 500, - statusText: "Internal Server Error", - text: async () => "server error", - }); + mockFetchResponse( + textResponse("server error", { status: 500, statusText: "Internal Server Error" }), + ); await expect(pdfNativeProviders.geminiAnalyzePdf(makeGeminiAnalyzeParams())).rejects.toThrow( "Gemini PDF request failed", @@ -257,10 +348,7 @@ describe("native PDF provider API calls", () => { }); it("geminiAnalyzePdf throws when no candidates returned", async () => { - mockFetchResponse({ - ok: true, - json: async () => ({ candidates: [] }), - }); + mockFetchResponse(jsonResponse({ candidates: [] })); await expect(pdfNativeProviders.geminiAnalyzePdf(makeGeminiAnalyzeParams())).rejects.toThrow( "Gemini PDF returned no candidates", @@ -268,12 +356,11 @@ describe("native PDF provider API calls", () => { }); it("anthropicAnalyzePdf supports multiple PDFs", async () => { - const fetchMock = mockFetchResponse({ - ok: true, - json: async () => ({ + const fetchMock = mockFetchResponse( + jsonResponse({ content: [{ type: "text", text: "Multi-doc analysis" }], }), - }); + ); await pdfNativeProviders.anthropicAnalyzePdf( makeAnthropicAnalyzeParams({ @@ -295,12 +382,11 @@ describe("native PDF provider API calls", () => { }); it("anthropicAnalyzePdf uses custom base URL", async () => { - const fetchMock = mockFetchResponse({ - ok: true, - json: async () => ({ + const fetchMock = mockFetchResponse( + jsonResponse({ content: [{ type: "text", text: "ok" }], }), - }); + ); await pdfNativeProviders.anthropicAnalyzePdf( makeAnthropicAnalyzeParams({ baseUrl: "https://custom.example.com" }), @@ -322,12 +408,11 @@ describe("native PDF provider API calls", () => { }); it("geminiAnalyzePdf does not duplicate /v1beta when baseUrl already includes it", async () => { - const fetchMock = mockFetchResponse({ - ok: true, - json: async () => ({ + const fetchMock = mockFetchResponse( + jsonResponse({ candidates: [{ content: { parts: [{ text: "ok" }] } }], }), - }); + ); await pdfNativeProviders.geminiAnalyzePdf( makeGeminiAnalyzeParams({ @@ -341,12 +426,11 @@ describe("native PDF provider API calls", () => { }); it("geminiAnalyzePdf normalizes bare Google API hosts to a single /v1beta root", async () => { - const fetchMock = mockFetchResponse({ - ok: true, - json: async () => ({ + const fetchMock = mockFetchResponse( + jsonResponse({ candidates: [{ content: { parts: [{ text: "ok" }] } }], }), - }); + ); await pdfNativeProviders.geminiAnalyzePdf( makeGeminiAnalyzeParams({ diff --git a/src/agents/tools/pdf-native-providers.ts b/src/agents/tools/pdf-native-providers.ts index 9890d3a518b3..fcb5529a0ce0 100644 --- a/src/agents/tools/pdf-native-providers.ts +++ b/src/agents/tools/pdf-native-providers.ts @@ -4,10 +4,17 @@ */ import { readResponseBodySnippet } from "../../infra/http-error-body.js"; +import { + postJsonRequest, + readProviderJsonResponse, + resolveProviderHttpRequestConfigWithOriginTrust, +} from "../../media-understanding/shared.js"; import { normalizeProviderTransportWithPlugin } from "../../plugins/provider-runtime.js"; import { isRecord } from "../../utils.js"; import { normalizeSecretInput } from "../../utils/normalize-secret-input.js"; import { resolveAnthropicMessagesUrl } from "../anthropic-transport-stream.js"; +import type { ModelProviderRequestTransportOverrides } from "../provider-request-config.js"; +import { resolveProviderTransportSsrFPolicy } from "../provider-transport-fetch.js"; type PdfInput = { base64: string; @@ -18,6 +25,56 @@ const NATIVE_PDF_PROVIDER_FETCH_TIMEOUT_MS = 120_000; const NATIVE_PDF_ERROR_BODY_MAX_BYTES = 8 * 1024; const NATIVE_PDF_ERROR_BODY_MAX_CHARS = 400; +type NativePdfProviderRequestConfig = { + headers?: Record; + request?: ModelProviderRequestTransportOverrides; +}; + +type NativePdfJsonRequest = { + url: string; + headers: Headers; + body: unknown; + allowPrivateNetwork: boolean; + ssrfPolicy: Parameters[0]["ssrfPolicy"]; + dispatcherPolicy: Parameters[0]["dispatcherPolicy"]; + failureLabel: string; + responseLabel: string; + nonJsonMessage: string; +}; + +async function postNativePdfJson(params: NativePdfJsonRequest): Promise> { + const { response, release } = await postJsonRequest({ + url: params.url, + headers: params.headers, + body: params.body, + timeoutMs: NATIVE_PDF_PROVIDER_FETCH_TIMEOUT_MS, + fetchFn: fetch, + allowPrivateNetwork: params.allowPrivateNetwork, + ssrfPolicy: params.ssrfPolicy, + dispatcherPolicy: params.dispatcherPolicy, + }); + + try { + if (!response.ok) { + const body = await readResponseBodySnippet(response, { + maxBytes: NATIVE_PDF_ERROR_BODY_MAX_BYTES, + maxChars: NATIVE_PDF_ERROR_BODY_MAX_CHARS, + }); + throw new Error( + `${params.failureLabel} (${response.status} ${response.statusText})${body ? `: ${body}` : ""}`, + ); + } + + const json = await readProviderJsonResponse(response, params.responseLabel); + if (!isRecord(json)) { + throw new Error(params.nonJsonMessage); + } + return json; + } finally { + await release(); + } +} + // --------------------------------------------------------------------------- // Anthropic – native PDF via Messages API // --------------------------------------------------------------------------- @@ -47,6 +104,7 @@ export async function anthropicAnalyzePdf(params: { pdfs: PdfInput[]; maxTokens?: number; baseUrl?: string; + requestConfig?: NativePdfProviderRequestConfig; }): Promise { const apiKey = normalizeSecretInput(params.apiKey); if (!apiKey) { @@ -66,37 +124,46 @@ export async function anthropicAnalyzePdf(params: { } content.push({ type: "text", text: params.prompt }); - const res = await fetch(resolveAnthropicMessagesUrl(params.baseUrl), { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": apiKey, - "anthropic-version": "2023-06-01", - "anthropic-beta": "pdfs-2024-09-25", - }, - body: JSON.stringify({ + const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy, trustConfiguredBaseUrlOrigin } = + resolveProviderHttpRequestConfigWithOriginTrust({ + baseUrl: params.baseUrl, + defaultBaseUrl: resolveAnthropicMessagesUrl(undefined).replace(/\/messages$/u, ""), + defaultHeaders: { + ...params.requestConfig?.headers, + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + "anthropic-beta": "pdfs-2024-09-25", + }, + request: params.requestConfig?.request, + provider: "anthropic", + api: "anthropic-messages", + capability: "other", + transport: "http", + }); + headers.set("Content-Type", "application/json"); + const url = resolveAnthropicMessagesUrl(baseUrl); + + const json = await postNativePdfJson({ + url, + headers, + body: { model: params.modelId, max_tokens: params.maxTokens ?? 4096, messages: [{ role: "user", content }], + }, + allowPrivateNetwork, + ssrfPolicy: resolveProviderTransportSsrFPolicy({ + baseUrl, + url, + allowPrivateNetwork, + trustConfiguredBaseUrlOrigin, }), - signal: AbortSignal.timeout(NATIVE_PDF_PROVIDER_FETCH_TIMEOUT_MS), + dispatcherPolicy, + failureLabel: "Anthropic PDF request failed", + responseLabel: "Anthropic PDF response", + nonJsonMessage: "Anthropic PDF response was not JSON.", }); - if (!res.ok) { - const body = await readResponseBodySnippet(res, { - maxBytes: NATIVE_PDF_ERROR_BODY_MAX_BYTES, - maxChars: NATIVE_PDF_ERROR_BODY_MAX_CHARS, - }); - throw new Error( - `Anthropic PDF request failed (${res.status} ${res.statusText})${body ? `: ${body}` : ""}`, - ); - } - - const json = (await res.json().catch(() => null)) as unknown; - if (!isRecord(json)) { - throw new Error("Anthropic PDF response was not JSON."); - } - const responseContent = json.content as AnthropicResponseContent | undefined; if (!Array.isArray(responseContent)) { throw new Error("Anthropic PDF response missing content array."); @@ -130,6 +197,7 @@ export async function geminiAnalyzePdf(params: { prompt: string; pdfs: PdfInput[]; baseUrl?: string; + requestConfig?: NativePdfProviderRequestConfig; }): Promise { const apiKey = normalizeSecretInput(params.apiKey); if (!apiKey) { @@ -155,35 +223,42 @@ export async function geminiAnalyzePdf(params: { baseUrl: params.baseUrl, }, }) ?? { baseUrl: params.baseUrl }; - const baseUrl = (transport.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta").replace( - /\/v1beta$/i, - "", - ); - const url = `${baseUrl}/v1beta/models/${encodeURIComponent(params.modelId)}:generateContent`; - - const res = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json", "x-goog-api-key": apiKey }, - body: JSON.stringify({ - contents: [{ role: "user", parts }], - }), - signal: AbortSignal.timeout(NATIVE_PDF_PROVIDER_FETCH_TIMEOUT_MS), - }); - - if (!res.ok) { - const body = await readResponseBodySnippet(res, { - maxBytes: NATIVE_PDF_ERROR_BODY_MAX_BYTES, - maxChars: NATIVE_PDF_ERROR_BODY_MAX_CHARS, + const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy, trustConfiguredBaseUrlOrigin } = + resolveProviderHttpRequestConfigWithOriginTrust({ + baseUrl: transport.baseUrl, + defaultBaseUrl: "https://generativelanguage.googleapis.com/v1beta", + defaultHeaders: { + ...params.requestConfig?.headers, + "x-goog-api-key": apiKey, + }, + request: params.requestConfig?.request, + provider: "google", + api: "google-generative-ai", + capability: "other", + transport: "http", }); - throw new Error( - `Gemini PDF request failed (${res.status} ${res.statusText})${body ? `: ${body}` : ""}`, - ); - } + headers.set("Content-Type", "application/json"); + const normalizedBaseUrl = baseUrl.replace(/\/v1beta$/i, ""); + const url = `${normalizedBaseUrl}/v1beta/models/${encodeURIComponent(params.modelId)}:generateContent`; - const json = (await res.json().catch(() => null)) as unknown; - if (!isRecord(json)) { - throw new Error("Gemini PDF response was not JSON."); - } + const json = await postNativePdfJson({ + url, + headers, + body: { + contents: [{ role: "user", parts }], + }, + allowPrivateNetwork, + ssrfPolicy: resolveProviderTransportSsrFPolicy({ + baseUrl, + url, + allowPrivateNetwork, + trustConfiguredBaseUrlOrigin, + }), + dispatcherPolicy, + failureLabel: "Gemini PDF request failed", + responseLabel: "Gemini PDF response", + nonJsonMessage: "Gemini PDF response was not JSON.", + }); const candidates = json.candidates as GeminiCandidate[] | undefined; if (!Array.isArray(candidates) || candidates.length === 0) { diff --git a/src/agents/tools/pdf-tool.ts b/src/agents/tools/pdf-tool.ts index 89124b31a2e3..ca2fc6f06ad7 100644 --- a/src/agents/tools/pdf-tool.ts +++ b/src/agents/tools/pdf-tool.ts @@ -19,6 +19,7 @@ import { extractPdfContent, type PdfExtractedContent } from "../../media/pdf-ext import { loadWebMediaRaw } from "../../media/web-media.js"; import { resolveUserPath } from "../../utils.js"; import type { AuthProfileStore } from "../auth-profiles/types.js"; +import { getModelProviderRequestTransport } from "../provider-request-config.js"; import { optionalFiniteNumberSchema } from "../schema/typebox.js"; import { readFiniteNumberParam, ToolInputError } from "./common.js"; import { coerceImageModelConfig, type ImageModelConfig } from "./image-tool.helpers.js"; @@ -208,6 +209,10 @@ async function runPdfPrompt(params: { pdfs, maxTokens: resolvePdfToolMaxTokens(model.maxTokens), baseUrl: model.baseUrl, + requestConfig: { + headers: model.headers, + request: getModelProviderRequestTransport(model), + }, }); return { text, provider, model: modelId, native: true }; } @@ -219,6 +224,10 @@ async function runPdfPrompt(params: { prompt: params.prompt, pdfs, baseUrl: model.baseUrl, + requestConfig: { + headers: model.headers, + request: getModelProviderRequestTransport(model), + }, }); return { text, provider, model: modelId, native: true }; } diff --git a/src/media-understanding/shared.test.ts b/src/media-understanding/shared.test.ts index 0846de09baf4..92dad93d7f06 100644 --- a/src/media-understanding/shared.test.ts +++ b/src/media-understanding/shared.test.ts @@ -40,8 +40,9 @@ import { pollProviderOperationJson, postJsonRequest, postTranscriptionRequest, - resolveProviderOperationTimeoutMs, resolveProviderHttpRequestConfig, + resolveProviderHttpRequestConfigWithOriginTrust, + resolveProviderOperationTimeoutMs, waitProviderOperationPollInterval, } from "./shared.js"; @@ -537,6 +538,30 @@ describe("resolveProviderHttpRequestConfig", () => { expect(resolved.headers.get("x-goog-api-key")).toBe("test-key"); }); + it("keeps configured-origin trust eligibility internal to core callers", () => { + const custom = resolveProviderHttpRequestConfigWithOriginTrust({ + baseUrl: "https://models.internal/v1", + defaultBaseUrl: "https://api.example.com/v1", + provider: "example", + }); + const deniedLocal = resolveProviderHttpRequestConfigWithOriginTrust({ + baseUrl: "http://127.0.0.1:11434/v1", + defaultBaseUrl: "https://api.example.com/v1", + provider: "example", + request: { allowPrivateNetwork: false }, + }); + + expect(custom.trustConfiguredBaseUrlOrigin).toBe(true); + expect(deniedLocal.trustConfiguredBaseUrlOrigin).toBe(false); + expect( + resolveProviderHttpRequestConfig({ + baseUrl: "https://models.internal/v1", + defaultBaseUrl: "https://api.example.com/v1", + provider: "example", + }), + ).not.toHaveProperty("trustConfiguredBaseUrlOrigin"); + }); + it("surfaces dispatcher policy for explicit proxy and mTLS transport overrides", () => { const resolved = resolveProviderHttpRequestConfig({ baseUrl: "https://api.deepgram.com/v1", diff --git a/src/media-understanding/shared.ts b/src/media-understanding/shared.ts index 00f99a52dd9e..dd37d956db09 100644 --- a/src/media-understanding/shared.ts +++ b/src/media-understanding/shared.ts @@ -324,7 +324,19 @@ function sanitizeAuditContext(auditContext: string | undefined): string | undefi return cleaned.slice(0, MAX_AUDIT_CONTEXT_CHARS); } -export function resolveProviderHttpRequestConfig(params: { +type ResolvedProviderHttpRequestConfig = { + baseUrl: string; + allowPrivateNetwork: boolean; + headers: Headers; + dispatcherPolicy?: PinnedDispatcherPolicy; + requestConfig: ResolvedProviderRequestConfig; +}; + +type ResolvedProviderHttpRequestConfigWithOriginTrust = ResolvedProviderHttpRequestConfig & { + trustConfiguredBaseUrlOrigin: boolean; +}; + +function resolveProviderHttpRequestConfigWithOriginTrustInternal(params: { baseUrl?: string; defaultBaseUrl: string; allowPrivateNetwork?: boolean; @@ -335,13 +347,7 @@ export function resolveProviderHttpRequestConfig(params: { api?: string; capability?: ProviderRequestCapability; transport?: ProviderRequestTransport; -}): { - baseUrl: string; - allowPrivateNetwork: boolean; - headers: Headers; - dispatcherPolicy?: PinnedDispatcherPolicy; - requestConfig: ResolvedProviderRequestConfig; -} { +}): ResolvedProviderHttpRequestConfigWithOriginTrust { const requestConfig = resolveProviderRequestPolicyConfig({ provider: params.provider ?? "", baseUrl: params.baseUrl, @@ -368,9 +374,32 @@ export function resolveProviderHttpRequestConfig(params: { headers, dispatcherPolicy: buildProviderRequestDispatcherPolicy(requestConfig), requestConfig, + trustConfiguredBaseUrlOrigin: + !requestConfig.privateNetworkExplicitlyDenied && + (requestConfig.policy.endpointClass === "custom" || + requestConfig.policy.endpointClass === "local"), }; } +export function resolveProviderHttpRequestConfig( + params: Parameters[0], +): ResolvedProviderHttpRequestConfig { + const resolved = resolveProviderHttpRequestConfigWithOriginTrustInternal(params); + return { + baseUrl: resolved.baseUrl, + allowPrivateNetwork: resolved.allowPrivateNetwork, + headers: resolved.headers, + dispatcherPolicy: resolved.dispatcherPolicy, + requestConfig: resolved.requestConfig, + }; +} + +export function resolveProviderHttpRequestConfigWithOriginTrust( + params: Parameters[0], +): ResolvedProviderHttpRequestConfigWithOriginTrust { + return resolveProviderHttpRequestConfigWithOriginTrustInternal(params); +} + /** * Decide whether to auto-upgrade a provider HTTP request into * `TRUSTED_ENV_PROXY` mode based on the runtime environment.