A Function Called getSafe
It fetches a URL and returns the body. It follows redirects. It validates nothing. The only safe thing about getSafe is the name.
Third one in this little series of AI-framework SSRFs, and this one I love purely for the name of the function at the centre of it. In embedJs — a “load anything into your vector store” toolkit — the web, sitemap, and PDF loaders all fetch their URL through a shared helper called getSafe().
You already know where this is going.
The boring part
The flow is the usual “add this source to my knowledge base”: user provides a URL, embedJs fetches it, chunks the body, embeds it. The one piece of validation in the whole path is a helper called isValidURL(). Reassuring. Let’s read it:
export function isValidURL(candidateUrl) {
try {
const url = new URL(candidateUrl);
return url.protocol === 'http:' || url.protocol === 'https:'; // scheme. that's it.
} catch { return false; }
}
It checks the scheme. http/https → valid. That’s the entire check. http://169.254.169.254/ is a perfectly valid http: URL, so it sails through.
The tilt-your-head part
But surely the fetch helper, the one named getSafe, does the real work? Here is the complete function — not an excerpt, the whole thing:
export async function getSafe(url, options) {
const headers = options?.headers ?? {};
headers['User-Agent'] ??= DEFAULT_USER_AGENT;
const format = options?.format ?? 'stream';
const response = await fetch(url, { headers }); // no validation; redirects followed
if (response.status !== 200) throw new Error(...);
return { body: /* text | buffer | stream */, statusCode: response.status, headers: response.headers };
}
There is no guard above the fetch. There is no guard below it. The only branching is which format to return the body in. getSafe is fetch with a User-Agent and a nicer name. It follows redirects by default, and it hands the body back to the caller — a readable SSRF, and the “safe” in the identifier is doing zero work.
The SitemapLoader makes it worse: it fetches an attacker-supplied sitemap and then spins up a WebLoader for every URL inside it. Point it at a sitemap you wrote and you’ve turned one SSRF into an internal port scanner.
Proof
Against published @llm-tools/embedjs-loader-web@0.1.31 (current latest), verbatim from the run:
=== [1] isValidURL() — the only check embedJs performs === ACCEPTED http://169.254.169.254/latest/meta-data/ ACCEPTED http://[::ffff:169.254.169.254]/ ACCEPTED http://127.0.0.1/ ACCEPTED http://metadata.google.internal/computeMetadata/v1/ ^ only the scheme is checked; every internal host is accepted. === [2] WebLoader direct to loopback "IMDS" === content: "internal-metadata-service role: admin-instance-role SECRET-IMDS-TOKEN=07e8cb93 ..." SSRF SUCCESS — internal secret returned
The fix
getSafe has to earn its name: resolve the host, reject loopback / RFC1918 / link-local / 169.254.169.254 / IPv4-mapped-IPv6, fail closed on parse failure, redirect: "manual" with per-hop re-validation, and pin the connection to the validated IP to stop DNS rebinding. isValidURL should do more than scheme-check, or the destination check should move into getSafe itself so every caller is covered.
Where this stands
Present on @llm-tools/embedjs-*@0.1.31 — current latest across the packages — no CVE, no fix. Full disclosure. The network-layer IMDS lockdown is, again, the mitigation that doesn’t wait on the library.
Takeaways
- A name is not a control.
getSafe,validateSafeUrl,sanitize— read the body, because the identifier is marketing until proven otherwise. - Scheme validation is not destination validation.
http://being a valid scheme tells you nothing about where it points. - Fan-out loaders amplify. A sitemap loader that fetches every child URL turns a single SSRF into a scanner. Rate and destination limits matter double there.
Full disclosure. Present on @llm-tools/embedjs-loader-web / @llm-tools/embedjs-utils 0.1.31 (current latest) at time of writing; no CVE assigned, no fix released. Reproduced against the published packages; PoC uses loopback stand-ins only.