Spaces:
Runtime error
Runtime error
File size: 6,717 Bytes
46252cd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 | 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--;
}
}
|