From f6feb4144c272327f763d7db244a739b3c00b4a5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 18:13:29 +0100 Subject: [PATCH] refactor(memory): add guarded remote HTTP helper --- src/memory/batch-http.ts | 38 +++++++++++++++---------- src/memory/embeddings-remote-fetch.ts | 36 +++++++++++++++--------- src/memory/remote-http.ts | 40 +++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 28 deletions(-) create mode 100644 src/memory/remote-http.ts diff --git a/src/memory/batch-http.ts b/src/memory/batch-http.ts index 24405e20ba3..de7ad23f43b 100644 --- a/src/memory/batch-http.ts +++ b/src/memory/batch-http.ts @@ -1,27 +1,36 @@ +import type { SsrFPolicy } from "../infra/net/ssrf.js"; import { retryAsync } from "../infra/retry.js"; +import { withRemoteHttpResponse } from "./remote-http.js"; export async function postJsonWithRetry(params: { url: string; headers: Record; + ssrfPolicy?: SsrFPolicy; body: unknown; errorPrefix: string; }): Promise { - const res = await retryAsync( + return await retryAsync( async () => { - const res = await fetch(params.url, { - method: "POST", - headers: params.headers, - body: JSON.stringify(params.body), + return await withRemoteHttpResponse({ + url: params.url, + ssrfPolicy: params.ssrfPolicy, + init: { + method: "POST", + headers: params.headers, + body: JSON.stringify(params.body), + }, + onResponse: async (res) => { + if (!res.ok) { + const text = await res.text(); + const err = new Error(`${params.errorPrefix}: ${res.status} ${text}`) as Error & { + status?: number; + }; + err.status = res.status; + throw err; + } + return (await res.json()) as T; + }, }); - if (!res.ok) { - const text = await res.text(); - const err = new Error(`${params.errorPrefix}: ${res.status} ${text}`) as Error & { - status?: number; - }; - err.status = res.status; - throw err; - } - return res; }, { attempts: 3, @@ -34,5 +43,4 @@ export async function postJsonWithRetry(params: { }, }, ); - return (await res.json()) as T; } diff --git a/src/memory/embeddings-remote-fetch.ts b/src/memory/embeddings-remote-fetch.ts index 5fa77e3d087..af8f5b33ac6 100644 --- a/src/memory/embeddings-remote-fetch.ts +++ b/src/memory/embeddings-remote-fetch.ts @@ -1,21 +1,31 @@ +import type { SsrFPolicy } from "../infra/net/ssrf.js"; +import { withRemoteHttpResponse } from "./remote-http.js"; + export async function fetchRemoteEmbeddingVectors(params: { url: string; headers: Record; + ssrfPolicy?: SsrFPolicy; body: unknown; errorPrefix: string; }): Promise { - const res = await fetch(params.url, { - method: "POST", - headers: params.headers, - body: JSON.stringify(params.body), + return await withRemoteHttpResponse({ + url: params.url, + ssrfPolicy: params.ssrfPolicy, + init: { + method: "POST", + headers: params.headers, + body: JSON.stringify(params.body), + }, + onResponse: async (res) => { + if (!res.ok) { + const text = await res.text(); + throw new Error(`${params.errorPrefix}: ${res.status} ${text}`); + } + const payload = (await res.json()) as { + data?: Array<{ embedding?: number[] }>; + }; + const data = payload.data ?? []; + return data.map((entry) => entry.embedding ?? []); + }, }); - if (!res.ok) { - const text = await res.text(); - throw new Error(`${params.errorPrefix}: ${res.status} ${text}`); - } - const payload = (await res.json()) as { - data?: Array<{ embedding?: number[] }>; - }; - const data = payload.data ?? []; - return data.map((entry) => entry.embedding ?? []); } diff --git a/src/memory/remote-http.ts b/src/memory/remote-http.ts new file mode 100644 index 00000000000..5a05dcdc40c --- /dev/null +++ b/src/memory/remote-http.ts @@ -0,0 +1,40 @@ +import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; +import type { SsrFPolicy } from "../infra/net/ssrf.js"; + +export function buildRemoteBaseUrlPolicy(baseUrl: string): SsrFPolicy | undefined { + const trimmed = baseUrl.trim(); + if (!trimmed) { + return undefined; + } + try { + const parsed = new URL(trimmed); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return undefined; + } + // Keep policy tied to the configured host so private operator endpoints + // continue to work, while cross-host redirects stay blocked. + return { allowedHostnames: [parsed.hostname] }; + } catch { + return undefined; + } +} + +export async function withRemoteHttpResponse(params: { + url: string; + init?: RequestInit; + ssrfPolicy?: SsrFPolicy; + auditContext?: string; + onResponse: (response: Response) => Promise; +}): Promise { + const { response, release } = await fetchWithSsrFGuard({ + url: params.url, + init: params.init, + policy: params.ssrfPolicy, + auditContext: params.auditContext ?? "memory-remote", + }); + try { + return await params.onResponse(response); + } finally { + await release(); + } +}