import { withSafeFetch } from '../../common/security/ssrf-guard'; /** Default + hard-cap timeout for a plugin's outbound request. */ const DEFAULT_TIMEOUT_MS = 15000; const MAX_TIMEOUT_MS = 30000; /** Cap the buffered response body so a hostile endpoint can't exhaust host memory through a plugin. */ const MAX_BODY_BYTES = 10 * 1024 * 1024; /** * Global cap on concurrent plugin fetches. Each buffers up to MAX_BODY_BYTES host-side (outside the * worker heap cap), so without a ceiling many concurrent fetches across plugins/workers could OOM the * host. This bounds total plugin-fetch buffering to MAX_INFLIGHT_FETCHES × MAX_BODY_BYTES regardless of * plugin or worker count. Reject-when-full (mirrors the sandbox in-flight-cap pattern) instead of an * unbounded queue, so abuse fails fast rather than deferring the memory blow-up. */ const MAX_INFLIGHT_FETCHES = 16; let inFlightFetches = 0; /** Request a sandboxed plugin may make through ctx.net.fetch. Body may be a string (text/JSON) or raw * bytes (binary uploads, e.g. multipart) — a Uint8Array survives the worker structuredClone bridge. */ export interface PluginNetRequestInit { method?: string; headers?: Record; body?: string | Uint8Array; timeoutMs?: number; } /** * Serializable response handed back to the plugin. No streaming / no methods — it must cross the * worker boundary via structuredClone, so the body is read host-side and returned as a string. */ export interface PluginNetResponse { ok: boolean; status: number; statusText: string; headers: Record; body: string; } /** * The effective outbound-host allowlist for a plugin: its static manifest `net.allow` plus the host of * every `net.allowConfigHosts` config key that resolves to an https URL. Lets a marketplace adapter reach * an operator-configured host (e.g. a Chatwoot base URL) without `net.allow:['*']`. Credentialed or * non-https values are ignored; the SSRF guard still blocks private IPs at connect regardless. */ export function effectiveNetAllow( allow: string[] | undefined, allowConfigHosts: string[] | undefined, config: Record, ): string[] { const out = [...(allow ?? [])]; for (const key of allowConfigHosts ?? []) { const raw = config[key]; if (typeof raw !== 'string') continue; try { const u = new URL(raw); if (u.protocol !== 'https:' || u.username || u.password) continue; if (u.hostname.includes('*')) continue; // never let a config value inject the '*' wildcard sentinel out.push(u.host); // host:port when a port is set, else bare host } catch { // Not a URL — skip. } } return out; } /** * Is `url` allowed by a plugin's manifest `net.allow` list? Deny-by-default. `'*'` allows any host * (the SSRF guard still blocks internal IPs at connect time); an entry may be `host:port` (exact) or * a bare `host` (any port). Only http(s) is ever allowed. */ export function isNetHostAllowed(allow: string[] | undefined, url: string): boolean { let parsed: URL; try { parsed = new URL(url); } catch { return false; } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; const list = allow ?? []; if (list.includes('*')) return true; const port = parsed.port || (parsed.protocol === 'https:' ? '443' : '80'); return list.includes(`${parsed.hostname}:${port}`) || list.includes(parsed.hostname); } /** * Perform a plugin's outbound request through the SSRF guard (resolve-once-pin, redirect-refused), * bounded by a timeout and a response-size cap, and serialize the response for the capability bridge. * `deps.fetch` is injectable for tests; production uses {@link withSafeFetch}. */ export async function performPluginFetch( url: string, init: PluginNetRequestInit = {}, deps: { fetch?: typeof withSafeFetch } = {}, ): Promise { const safeFetch = deps.fetch ?? withSafeFetch; // Reject-when-full BEFORE reserving a slot, so total concurrent host-side buffering stays bounded to // MAX_INFLIGHT_FETCHES × MAX_BODY_BYTES. Check + increment are synchronous (single event-loop turn), // so no interleaving can overshoot the cap; the slot is released in the finally below. if (inFlightFetches >= MAX_INFLIGHT_FETCHES) { throw new Error(`too many concurrent plugin net.fetch calls (max ${MAX_INFLIGHT_FETCHES}); retry shortly`); } inFlightFetches++; // Coerce a non-finite timeoutMs (a string/object/NaN from the untrusted worker) to the default // instead of letting it flow through as NaN — `Math.max('abc', 1)` is NaN, and AbortSignal.timeout(NaN) // throws a RangeError, silently defeating the documented default + hard-cap clamp. const requested = typeof init.timeoutMs === 'number' && Number.isFinite(init.timeoutMs) ? init.timeoutMs : DEFAULT_TIMEOUT_MS; const timeoutMs = Math.min(Math.max(requested, 1), MAX_TIMEOUT_MS); try { return await safeFetch( url, { method: init.method ?? 'GET', headers: init.headers, body: init.body, signal: AbortSignal.timeout(timeoutMs), }, async response => { const declared = Number(response.headers.get('content-length') ?? ''); if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) { throw new Error(`plugin net.fetch response exceeds the ${MAX_BODY_BYTES}-byte cap`); } // Stream with a running cap so a chunked response without an honest content-length can't blow // past the limit (arrayBuffer() would buffer the whole body first). Mirrors plugin-download. const reader = response.body?.getReader(); const chunks: Buffer[] = []; let total = 0; if (reader) { for (;;) { const { done, value } = (await reader.read()) as { done: boolean; value?: Uint8Array }; if (done) break; if (!value) continue; total += value.byteLength; if (total > MAX_BODY_BYTES) { await reader.cancel().catch(() => undefined); throw new Error(`plugin net.fetch response exceeds the ${MAX_BODY_BYTES}-byte cap`); } chunks.push(Buffer.from(value)); } } const headers: Record = {}; response.headers.forEach((value, key) => { headers[key] = value; }); return { ok: response.ok, status: response.status, statusText: response.statusText, headers, body: Buffer.concat(chunks).toString('utf-8'), }; }, ); } finally { inFlightFetches--; } }