ssrf-guard-js v0.7.1

Stop the fetch you should not make, before your server makes it.

`@devslab/ssrf-guard-js` is an SSRF guard for TypeScript and JavaScript: host allowlists, private-IP blocking, redirect revalidation, and LLM tool-input scanning. On Node it adds DNS checks and optional pinning (`safeFetch`); on edge runtimes like Cloudflare Workers and browsers it runs URL-time validation plus per-hop redirect checks (`guardedFetch`). Every example below is copy-and-run.

Node 22+ Workers / Edge TypeScript ready Apache-2.0 LLM tool guard

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));
They 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. It is a URL-time answer only: `safeFetch`'s DNS checks have no non-throwing equivalent, because knowing that requires actually resolving.

`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);
Install the optional `undici` dependency and `safeFetch` pins the DNS check and the socket to a single resolution, closing the DNS-rebinding window (the `pinDns` option). For high-risk crawling of arbitrary URLs, use a strict allowlist or a dedicated guarded egress service.

`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'],
});
Both `safeFetch` and `guardedFetch` accept an `onFinalUrl` callback, called with the final validated URL once every redirect hop has been followed. Some fetch implementations (including custom `fetchImpl`s) leave `Response.url` empty, so this callback is the more reliable way to attribute fetched content to its true origin.

`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
Exceeding it 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, so a bad env parse cannot quietly switch the cap off.

`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));
Locking the port is the substance of it. The default `allowedPorts` is `[-1, 80, 443]`, so a hand-written `{ exactHosts: [u.hostname] }` derived from a base like `https://api.example.com:8443/v1` **rejects its own base URL** — quietly, and only on the non-standard-port deployments.

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 });
});
Bodies scanned: `application/json` (and `+json`) and `application/x-www-form-urlencoded`. **`multipart/form-data` is not scanned** — parsing it would buffer uploaded files inside a check that runs on every request. Hono caches parsed bodies, so the middleware reading the body does not consume it and your handler's `c.req.json()` still resolves (verified against real Hono).

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.
}
By default only strings whose whole value is a URL are flagged. To also catch a URL buried mid-sentence — `"summarize http://169.254.169.254/ please"` — opt into `{ scanEmbedded: true }`. It is strictly additive (everything the base scanner flagged stays flagged) and validates URL-shaped text inside prose and code snippets against the policy too.

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