UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

131 lines (130 loc) • 4.76 kB
import { isIP } from "node:net"; import { imageMagicMatches } from "../../tools/fs/safety.js"; import { ADMITTED_IMAGE_MIME_TYPES } from "../../tools/fs/image-admission.js"; import { admitInlineImage } from "./inline-image.js"; function sniffImageMime(bytes) { return ADMITTED_IMAGE_MIME_TYPES.find((mime) => imageMagicMatches(bytes, mime)); } function isPrivateV4(ip) { const o = ip.split(".").map(Number); if (o.length !== 4 || o.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true; const [a, b] = o; return (a === 127 || a === 10 || a === 0 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254) || (a === 100 && b >= 64 && b <= 127)); } function embeddedV4(h) { const fromHex = (hi, lo) => { const a = parseInt(hi, 16), c = parseInt(lo, 16); return `${(a >> 8) & 255}.${a & 255}.${(c >> 8) & 255}.${c & 255}`; }; let m = /^(?:::ffff:|64:ff9b::)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(h); if (m) return m[1]; m = /^(?:::ffff:|64:ff9b::)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(h); if (m) return fromHex(m[1], m[2]); return undefined; } export function isPrivateHost(host) { const h = host .replace(/^\[|\]$/g, "") .replace(/\.+$/, "") .toLowerCase(); if (h === "localhost") return true; const fam = isIP(h); if (fam === 0) return false; if (fam === 4) return isPrivateV4(h); const v4 = embeddedV4(h); if (v4) return isPrivateV4(v4); if (h === "::1" || h === "::") return true; return /^fe[89ab]/.test(h) || /^f[cd]/.test(h) || /^fe[c-f]/.test(h); } export function defaultImageUrlGuard(raw) { let u; try { u = new URL(raw); } catch { throw new Error(`invalid image url: ${raw}`); } if (u.protocol !== "https:") { throw new Error(`image url must be https (got "${u.protocol}")`); } if (isPrivateHost(u.hostname)) { throw new Error(`image url host not allowed (private/reserved): ${u.hostname}`); } } const IMAGE_FETCH_TIMEOUT_MS = 30_000; const IMAGE_MAX_BYTES = 20 * 1024 * 1024; async function readImageBodyCapped(res) { const reader = res.body?.getReader(); if (!reader) { const ab = await res.arrayBuffer(); if (ab.byteLength > IMAGE_MAX_BYTES) throw new Error(`image too large (> ${IMAGE_MAX_BYTES} cap)`); return Buffer.from(ab); } const chunks = []; let total = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; if (!value) continue; total += value.byteLength; if (total > IMAGE_MAX_BYTES) { await reader.cancel().catch(() => undefined); throw new Error(`image exceeds ${IMAGE_MAX_BYTES}-byte cap (chunked/unbounded response)`); } chunks.push(value); } return Buffer.concat(chunks.map((c) => Buffer.from(c))); } async function fetchImageCapped(url) { const res = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(IMAGE_FETCH_TIMEOUT_MS) }); if (res.type === "opaqueredirect" || (res.status >= 300 && res.status < 400)) { throw new Error(`image url redirected (status ${res.status}) — refusing to follow (the SSRF guard only validates the initial host)`); } if (!res.ok) { throw new Error(`failed to fetch image: HTTP ${res.status}`); } const declared = Number(res.headers.get("content-length")); if (Number.isFinite(declared) && declared > IMAGE_MAX_BYTES) { throw new Error(`image too large: ${declared} bytes > ${IMAGE_MAX_BYTES} cap`); } const headerMimeType = res.headers.get("content-type")?.split(";")[0]; const buf = await readImageBodyCapped(res); const sniffed = sniffImageMime(buf); if (!sniffed) { const claim = headerMimeType ? ` (server claimed Content-Type: ${headerMimeType})` : " and the response carried no Content-Type header"; throw new Error(`image url content is not a recognized image format (png/jpeg/gif/webp)${claim}: ${url}`); } return { buf, mimeType: sniffed }; } export async function toImageContent(img, allowUrl) { if ("url" in img) { if (allowUrl) { if (!allowUrl(img.url)) { throw new Error(`image url rejected by policy: ${img.url}`); } } else { defaultImageUrlGuard(img.url); } const { buf, mimeType } = await fetchImageCapped(img.url); return { type: "image", data: buf.toString("base64"), mimeType }; } return admitInlineImage(img); }