Five-minute start
Create an empty folder and install the package.
mkdir ssrf-guard-demo
cd ssrf-guard-demo
npm init -y
npm pkg set type=module
npm install @devslab/ssrf-guard-js
Create `demo.mjs` and paste this in.
import { validateUrl } from '@devslab/ssrf-guard-js';
const policy = {
exactHosts: ['api.example.com'],
allowedSchemes: ['https'],
allowedPorts: [-1, 443],
};
validateUrl('https://api.example.com/v1/users', policy);
console.log('allowed');
try {
validateUrl('http://169.254.169.254/latest/meta-data/', policy);
} catch (error) {
console.log('blocked:', error.reason);
}
Run it.
node demo.mjs
Expected output:
allowed
blocked: blocked_ip_literal
The rules that matter most
1. Fail-closed by default
With no `exactHosts` and no `suffixes` configured, no host is allowed at all.
2. IP literals are blocked by default
Evasive forms like `127.0.0.1`, `2130706433` and `[::1]` are rejected at the URL stage.
3. Redirects are re-checked
`safeFetch` blocks a 302 whose Location points at a private IP or a host outside the policy.
Using the API
`validateUrl(input, policy)`
Check a URL before fetching it. Returns a `URL` on success, throws `SsrfGuardError` otherwise.
import { validateUrl } from '@devslab/ssrf-guard-js';
const url = validateUrl('https://api.example.com/data', {
exactHosts: ['api.example.com'],
allowedSchemes: ['https'],
allowedPorts: [-1, 443],
});
console.log(url.href);
`checkUrl(input, policy)` / `isUrlAllowed(input, policy)`
The same checks, asked without exceptions. Use these for the decisions that **surround** a fetch — which links a crawler enqueues, which of a batch of URLs to report as rejected.
import { checkUrl, isUrlAllowed } from '@devslab/ssrf-guard-js';
const result = checkUrl(link, policy);
if (!result.allowed) {
console.log(`skipped ${link}: ${result.error.reason}`);
}
const crawlable = links.filter((link) => isUrlAllowed(link, policy));
`safeFetch(input, policy, init?)`
A fetch helper with URL validation, DNS private-IP checks, and redirect revalidation.
import { safeFetch } from '@devslab/ssrf-guard-js';
const response = await safeFetch('https://api.example.com/data', {
exactHosts: ['api.example.com'],
allowedSchemes: ['https'],
allowedPorts: [-1, 443],
});
const text = await response.text();
console.log(text);
`guardedFetch(input, policy, init?)` — Workers / Edge
A guarded fetch that runs anywhere: `safeFetch` minus the DNS checks. Every redirect hop re-passes the policy, and credential headers are stripped on cross-origin redirects. Use it on runtimes without `dns.lookup`, such as Cloudflare Workers — there the allowlist is the primary control, and an empty one allows nothing (fail-closed).
import { guardedFetch } from '@devslab/ssrf-guard-js';
const response = await guardedFetch('https://api.example.com/data', {
exactHosts: ['api.example.com'],
allowedSchemes: ['https'],
});
`maxBytes` — response size cap
Accepted by both `safeFetch` and `guardedFetch`, so a response that streams without end cannot exhaust the caller. It is checked in two places: an oversized `Content-Length` is rejected before a byte is read, and a streaming byte count catches bodies that omit or understate it.
import { guardedFetch } from '@devslab/ssrf-guard-js';
const response = await guardedFetch(url, policy, { maxBytes: 2_000_000 });
const body = await response.text(); // rejects if the body runs past the cap
`sameSitePolicy(url, overrides?)`
The policy helper for "crawl the site the user just submitted". It derives the allowlist from the submitted URL and locks the whole fetch — redirects included — to that domain (a leading `www.` is stripped so apex to www redirects survive). To open extra hosts, add them to the overrides' `exactHosts`/`suffixes`; the bypass stays inside the policy.
import { guardedFetch, sameSitePolicy } from '@devslab/ssrf-guard-js';
const input = 'https://www.customer-site.example/about';
const response = await guardedFetch(input, sameSitePolicy(input, {
allowedSchemes: ['https'],
}));
`singleHostPolicy(url, overrides?)`
The sibling of `sameSitePolicy`, for the opposite intent. When the endpoint is already known — a registered API base, a webhook target, a configured upstream — 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 response = await guardedFetch(target, singleHostPolicy(registeredApiBase));
Express / Hono / Vite / LangChain
Hono: Workers-native middleware
Shipped as a separate entry point (`@devslab/ssrf-guard-js/hono`). 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'] }), async (c) => {
const { url } = await c.req.json(); // already validated
return c.json({ ok: true });
});
Express: one middleware line
Scans URLs in the request body and query, and returns a structured `400` JSON response when it finds a blocked one.
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 });
},
);
Vite: add the plugin to `vite.config.ts`
Use this when your Vite dev server has SSR/proxy endpoints that receive a URL and then fetch it server-side. It is not a tool for blocking every request a browser makes on its own.
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 request below is blocked by the dev-server middleware.
/api/crawl?url=http://169.254.169.254/latest/meta-data/
LangChain / agent tools: wrap the tool function
Scans the whole object the model passed to the tool, and runs the real tool function only when it is clean.
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();
}),
});
LLM tool-input URL scanning
Checking a top-level `url` field is not enough for LLM tool input: a hostile URL can hide in a nested object, an array, or an explanation string. `guardToolInputJson` walks the entire JSON tree.
import { guardToolInputJson } from '@devslab/ssrf-guard-js';
const toolInput = JSON.stringify({
request: {
target: 'http://169.254.169.254/latest/meta-data/',
},
});
const violation = guardToolInputJson(toolInput, {
exactHosts: ['api.example.com'],
});
if (violation) {
console.log(violation);
// Hand this string back to the model as the tool result.
}
Policy options
| Option | Default | What it does |
|---|---|---|
exactHosts |
[] |
Hosts that must match exactly. For example api.example.com. |
suffixes |
[] |
Allows example.com and every subdomain of it. Does not allow badexample.com. |
allowedSchemes |
['http', 'https'] |
Narrowing this to ['https'] is the usual recommendation in production. |
allowedPorts |
[-1, 80, 443] |
-1 covers URLs with no explicit port, such as https://api.example.com/. |
rejectIpLiteralHosts |
true |
Blocks URLs whose host is an IP address. |
rejectUserInfo |
true |
Blocks the https://user:pass@example.com form. |
blockPrivateNetworks |
true |
Blocks when DNS resolves to a loopback, private, link-local or metadata address. |
Block reasons
`SsrfGuardError.reason` is one of these stable strings.
blocked_scheme
blocked_host
blocked_port
blocked_ip_literal
blocked_userinfo
blocked_private_ip
blocked_redirect
blocked_response_size
blocked_other