qwen_2.5_model / src /core /plugins /plugin-net.ts
Muhammad Noman
Deploy OpenWA to Hugging Face Spaces
46252cd
Raw
History Blame Contribute Delete
6.72 kB
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<string, string>;
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<string, string>;
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, unknown>,
): 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<PluginNetResponse> {
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<PluginNetResponse>(
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<string, string> = {};
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--;
}
}