Docs · Roadmap · Decisions · Parity · 한국어
SSRF protection for JavaScript and TypeScript.
This is the JS/TS sibling of devslab-kr/ssrf-guard.
It ports the same core security model:
- URL-time validation: scheme, host allowlist, port, userinfo, IP-literal checks
- private-network IP classification
- LLM/tool-call JSON scanning for hidden URLs
- guarded fetch with URL, DNS, and redirect checks
pnpm add @devslab/ssrf-guard-jsFor a copy-paste tutorial, see the documentation site: https://devslab-kr.github.io/ssrf-guard-js/
import { validateUrl } from '@devslab/ssrf-guard-js';
validateUrl('https://api.example.com/v1', {
exactHosts: ['api.example.com'],
allowedSchemes: ['https'],
allowedPorts: [-1, 443],
});Empty exactHosts and suffixes are fail-closed: no host is allowed until
you configure one.
validateUrl throws. For the decisions that surround a fetch — which links
a crawler enqueues, which of a batch of URLs to report as rejected — ask
without exceptions:
import { checkUrl, isUrlAllowed } from '@devslab/ssrf-guard-js';
const result = checkUrl(link, policy);
if (!result.allowed) log.debug(`skipped ${link}: ${result.error.reason}`);
const crawlable = links.filter((link) => isUrlAllowed(link, policy));Both run the same code path as validateUrl, so the answer always agrees
with what the fetch guards would do — which a hand-written host comparison
does not. This is a URL-time answer only: safeFetch's DNS checks have no
non-throwing equivalent, because knowing that requires actually resolving.
Defaults:
allowedSchemes:['http', 'https']allowedPorts:[-1, 80, 443]— URLs without an explicit port count as the scheme's default port (http/ws→80,https/wss→443), soallowedPorts: [443]alone is enough for HTTPS-only policies.-1matches portless URLs of schemes without a known default.rejectIpLiteralHosts:truerejectUserInfo:trueblockPrivateNetworks:true
import { guardToolInputJson } from '@devslab/ssrf-guard-js';
const violation = guardToolInputJson(
JSON.stringify({ request: { target: 'http://169.254.169.254/latest/meta-data/' } }),
{ exactHosts: ['api.example.com'] },
);
if (violation) {
return violation; // structured JSON error for the model/tool caller
}The guard walks the entire JSON tree. A bad URL hidden inside a nested object,
array, or explanation field is still blocked. The scanner collects any
scheme:// URL (the policy's allowedSchemes decides the outcome, so
file:// or gopher:// are rejected by default) and protocol-relative
//host strings, which are validated against the host policy.
By default only strings whose whole (trimmed) value is a URL are flagged,
wherever they sit in the tree. To also catch URLs buried mid-sentence inside
longer strings — "summarize http://169.254.169.254/ please" — opt into
embedded scanning:
guardToolInputJson(toolInput, policy, { scanEmbedded: true });scanEmbedded is strictly additive (everything the base scanner flags stays
flagged) and deliberately aggressive: URL-shaped text inside prose or code
snippets is validated against the policy, so non-allowlisted hosts there
count as violations. Prose punctuation stuck to the URL tail
(…see https://spec.example/docs,) is trimmed before validation.
import { safeFetch } from '@devslab/ssrf-guard-js';
const response = await safeFetch('https://api.example.com/data', {
exactHosts: ['api.example.com'],
allowedSchemes: ['https'],
});safeFetch validates the URL, checks DNS results for private/local IPs
(failing closed if any resolved address is private), and revalidates every
redirect hop. On cross-origin redirects it strips Authorization,
Proxy-Authorization, and Cookie headers, and it downgrades 303 (and
301/302 POST) redirects to GET without replaying the request body.
Both safeFetch and guardedFetch accept an onFinalUrl callback that
receives the final validated URL once all redirect hops have been followed.
Use it to attribute the fetched content to its true origin — it is more
reliable than Response.url, which some fetch implementations (including
custom fetchImpls) leave empty. It is not called when the fetch throws.
let finalUrl: URL | undefined;
const response = await safeFetch(input, policy, {
onFinalUrl: (url) => {
finalUrl = url;
},
});Both also accept maxBytes, so a response that streams without end cannot
exhaust the caller:
const res = await guardedFetch(url, policy, { maxBytes: 2_000_000 });
const body = await res.text(); // rejects if the body runs past the capIt is checked twice, because either alone is insufficient: an oversized
Content-Length is rejected before a byte is read, and a streaming count
catches bodies that omit or understate it.
Exceeding the cap raises an SsrfGuardError with reason
blocked_response_size — never a silent truncation, which would hand
you a partial document with no signal that it is partial. If you want
truncation, catch the error and decide that yourself.
maxBytes must be a non-negative integer; anything else throws a
TypeError before the request is made, so a bad value cannot quietly turn
the cap off. A capped response is a new Response object, so
Response.url is not carried over — use onFinalUrl.
By default the DNS check and the actual connection resolve the hostname
separately, leaving a small DNS-rebinding window. Install the optional
undici dependency to close it:
pnpm add undiciWhen undici is present, safeFetch additionally validates the resolved
addresses inside the socket connector, so the check and the connection
share a single DNS resolution — the same socket-level pinning the Java Apache
HttpClient adapter uses. The pre-connect DNS check still runs in every mode:
pinning narrows the rebinding window, it is never the only check
(JS-016).
Control it explicitly with the pinDns option:
await safeFetch(url, policy, { pinDns: true }); // require pinning (throws without undici)
await safeFetch(url, policy, { pinDns: false }); // force check-then-fetchWithout undici, safeFetch falls back to check-then-fetch. That is a strong
guard rail, but use strict allowlists or a dedicated egress service for
high-risk arbitrary URL crawling.
Not every guarantee survives every runtime. Know which half of the package you are getting:
| Surface | Node | Bun | Deno | Cloudflare Workers |
|---|---|---|---|---|
validateUrl / UrlPolicy / HostPolicy |
✅ | ✅ | ✅ | ✅ (pure URL/string checks) |
guardToolInput / guardToolInputJson / createGuardedToolHandler |
✅ | ✅ | ✅ | ✅ |
guardedFetch + sameSitePolicy (URL-time + redirect revalidation) |
✅ | ✅ | ✅ | ✅ |
safeFetch (adds DNS checks) |
✅ | ✅ | ✅ | ❌ throws at runtime |
DNS pinning via undici |
✅ optional | ✅ optional | ❌ |
Verified on Node 24, Bun 1.3.3, and Deno 2.9.5 by installing the published package and running the public surface on each.
Agent({ connect: { lookup } }) and never calls the hook. Pinning is
therefore inert there — you keep every DNS check, but not the
rebinding-window closure that pinning exists for. Nothing silently
weakens: since 0.6.1 the private-IP check runs before connecting in every
mode, on every runtime (JS-016).
Security note for Bun users on
< 0.6.1: in those versions pinned mode was the only DNS check, so on BunsafeFetchperformed none at all and would fetch a host resolving to a private address. BecausepinDnsunset pins automatically wheneverundiciis installed, this was the default. Upgrade to 0.6.1; no code change is needed.
Deno note. Deno 2.x applies a minimum dependency age (24h by default)
before it will install a freshly published npm version, so deno add
picks the previous release for a day. That is Deno's supply-chain policy,
not a packaging problem — wait, or pass --min-dep-age 0.
Why safeFetch cannot work in Workers. It resolves the target with
node:dns/promises lookup before connecting. Workers' node:dns
(under the nodejs_compat flag) implements the resolve* functions via
DNS-over-HTTPS but lookup throws Not implemented — and even if it
resolved, Workers fetch performs its own resolution internally, so
userland cannot pin the checked IP to the socket the way the undici
connector does in Node. The check-then-fetch gap cannot be closed from
inside a Worker. (Importing the package is always safe — node:dns is
loaded lazily; calling safeFetch without it throws a typed
SsrfGuardError pointing here.)
What to use in Workers instead: guardedFetch. Same redirect
revalidation, credential stripping, and method-downgrade semantics as
safeFetch, minus the DNS checks — so the policy allowlist is the
primary control, and the fail-closed default (empty allowlist allows
nothing) is doing real work:
import { guardedFetch } from '@devslab/ssrf-guard-js';
const res = await guardedFetch('https://api.example.com/data', {
exactHosts: ['api.example.com'],
allowedSchemes: ['https'],
});To open a specific host, put it in the allowlist — that IS the bypass
mechanism, and it stays auditable in one place. For "crawl the customer's
own site" flows, derive the allowlist from the submitted URL with
sameSitePolicy: the whole fetch — redirects included — is locked to
that domain (www. stripped so apex ↔ www redirects survive):
import { guardedFetch, sameSitePolicy } from '@devslab/ssrf-guard-js';
const input = 'https://www.customer-site.example/about';
const res = await guardedFetch(input, sameSitePolicy(input, { allowedSchemes: ['https'] }));For genuinely arbitrary URL fetching from Workers, route the request
through a small Node-based egress service that calls safeFetch with
pinDns: true — the Worker talks to the egress service, never to the
user-supplied URL directly.
When the endpoint is already known — a registered API base, a webhook
target, a configured upstream — use singleHostPolicy instead. It locks
the fetch to that URL's origin: scheme, host, and port. No www.
peer, no subdomains.
import { guardedFetch, singleHostPolicy } from '@devslab/ssrf-guard-js';
const res = await guardedFetch(target, singleHostPolicy(registeredApiBase));sameSitePolicy |
singleHostPolicy |
|
|---|---|---|
| For | a site the user submitted | an endpoint you registered |
| Host | the domain, www. stripped, subdomains included |
that host exactly |
| Port | policy default (80/443) |
the base URL's port |
The port is the part worth noticing. The default allowedPorts is
[-1, 80, 443], so a hand-written { exactHosts: [u.hostname] } derived
from https://api.example.com:8443/v1 rejects its own base URL —
quietly, and only on the non-standard-port deployments.
import express from 'express';
import { createExpressUrlGuard } from '@devslab/ssrf-guard-js';
const app = express();
app.use(express.json());
app.post(
'/crawl',
createExpressUrlGuard({
exactHosts: ['example.com'],
suffixes: ['example.com'],
allowedSchemes: ['https'],
}),
async (req, res) => {
res.json({ ok: true });
},
);The middleware scans req.body and req.query by default. It returns a
structured 400 response when it finds a blocked URL.
The Workers-native counterpart, on its own entry point so it stays out of the root bundle. It is typed against the shape of a Hono context rather than importing Hono, so the package stays dependency-free.
import { Hono } from 'hono';
import { createHonoUrlGuard } from '@devslab/ssrf-guard-js/hono';
const app = new Hono();
app.post(
'/crawl',
createHonoUrlGuard({ suffixes: ['example.com'], allowedSchemes: ['https'] }),
async (c) => {
const { url } = await c.req.json(); // already validated
return c.json({ ok: true });
},
);Query parameters and the request body are scanned by default; path
parameters with { params: true }. A blocked URL gets the same
structured ssrf_blocked payload as the Express guard, at 400 unless
you set statusCode.
Which bodies are scanned: application/json (and +json) and
application/x-www-form-urlencoded. multipart/form-data is not —
parsing it here would buffer uploaded files inside a check that runs on
every request. Route uploads past this middleware, or validate their URL
fields in the handler.
Hono caches parsed bodies, so the middleware reading the body does not
consume it — your handler's own c.req.json() still resolves. That is
verified against real Hono, not assumed.
Use this when your Vite dev server has SSR/proxy endpoints that receive a URL and then fetch it server-side.
// vite.config.ts
import { defineConfig } from 'vite';
import { ssrfGuardVitePlugin } from '@devslab/ssrf-guard-js/vite';
export default defineConfig({
plugins: [
ssrfGuardVitePlugin({
routes: ['/api/crawl'],
policy: {
suffixes: ['example.com'],
allowedSchemes: ['https'],
},
}),
],
});The plugin scans query params named url, target, uri, and href by
default. Example blocked request:
/api/crawl?url=http://169.254.169.254/latest/meta-data/
createGuardedToolHandler wraps any object-input tool function without taking
a hard dependency on LangChain.
import { DynamicStructuredTool } from '@langchain/core/tools';
import { z } from 'zod';
import { createGuardedToolHandler, safeFetch } from '@devslab/ssrf-guard-js';
const policy = {
suffixes: ['example.com'],
allowedSchemes: ['https'],
};
export const fetchUrlTool = new DynamicStructuredTool({
name: 'fetch_url',
description: 'Fetch an allowed URL',
schema: z.object({ url: z.string().url() }),
func: createGuardedToolHandler(policy, async ({ url }) => {
const response = await safeFetch(url, policy);
return await response.text();
}),
});If the model tries to pass a private IP, metadata URL, or non-allowed host, the
tool returns a structured ssrf_blocked JSON string instead of fetching it.
Thrown SsrfGuardError instances expose stable reason values:
blocked_schemeblocked_hostblocked_portblocked_ip_literalblocked_userinfoblocked_private_ipblocked_redirectblocked_other
- ssrf-guard — the JVM sibling: the same security model for Spring Boot across 9 HTTP-client modules, including
-springai/-langchain4jfor LLM-agent tool URL validation - More open source from devslab