@sema-agent/core
Version:
Stateless, task-oriented AI agent core
131 lines (130 loc) • 4.76 kB
JavaScript
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);
}