Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@

### Pending Fixed

### v1.3.0 - 2026-06-06

- :rocket: Add allow list to allow specific URLs or IPs to bypass SSRF checks

### v1.2.0 - 2026-06-06

- :rocket: Expose a `fetch` wrapper that integrates `isSafeUrl()` checks to prevent SSRF vulnerabilities in HTTP requests
Expand Down
42 changes: 33 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ Lightweight TypeScript library for validating URLs to prevent Server-Side Reques

`node-safeurl` provides the following exports:

- **`fetch(input, init?)`** — SSRF-safe drop-in replacement for the global `fetch`. Validates the URL (and every redirect hop) before making the request, and returns a `TypedResponse`. Pass `{ safeUrl: false }` to opt out of validation.
- **`fetch(input, init?)`** — SSRF-safe drop-in replacement for the global `fetch`. Validates the URL (and every redirect hop) before making the request, and returns a `TypedResponse`. Pass `{ safeUrl: false }` to opt out of validation entirely, or `{ safeUrlAllow: [...] }` to allow specific private-network origins while keeping all other SSRF checks active.
- **`TypedResponse`** — Subclass of `Response` that adds a `.typed(schema)` method for runtime-validated JSON parsing via TypeBox.
- **`FetchInit`** — TypeScript interface extending `RequestInit` with the optional `safeUrl` boolean field.
- **`isSafeUrl(url)`** — Async function that validates a URL is safe to fetch. Checks protocol, hostname, IP literals, and performs DNS resolution to guard against DNS rebinding attacks.
- **`FetchInit`** — TypeScript interface extending `RequestInit` with the optional `safeUrl` boolean and `safeUrlAllow` string-array fields.
- **`isSafeUrl(href, opts?)`** — Async function that validates a URL is safe to fetch. Checks protocol, hostname, IP literals, and performs DNS resolution to guard against DNS rebinding attacks. Pass `{ allow: [...] }` to exempt specific origins or hostnames from all checks.
- **`SafeUrlOptions`** — TypeScript interface for the options accepted by `isSafeUrl`.
- **`isPrivateIPv4(address)`** — Synchronous check for private/special-purpose IPv4 addresses.
- **`isPrivateIPv6(address)`** — Synchronous check for private/special-purpose IPv6 addresses.

Expand All @@ -33,9 +34,14 @@ import { Type } from '@sinclair/typebox';
const res = await fetch('https://example.com/api/data');
const data = await res.typed(Type.Object({ id: Type.Number() }));

// Opt out of SSRF validation for trusted internal calls
// Opt out of SSRF validation entirely for trusted internal calls
const internal = await fetch('http://localhost:3000/health', { safeUrl: false });

// Allow a specific private-network origin while keeping all other SSRF checks active
const api = await fetch('http://10.0.0.5:8080/api/status', {
safeUrlAllow: ['http://10.0.0.5:8080'],
});

// Validate a URL manually before fetching
const result = await isSafeUrl('https://example.com/api');
if (result.safe) {
Expand All @@ -45,6 +51,11 @@ if (result.safe) {
console.error('Blocked:', result.reason);
}

// Validate a URL while allowing a known private endpoint
const result2 = await isSafeUrl('http://192.168.1.10/health', {
allow: ['192.168.1.10'],
});

// Check individual IPs
isPrivateIPv4('192.168.1.1'); // true
isPrivateIPv4('8.8.8.8'); // false
Expand All @@ -59,7 +70,8 @@ isPrivateIPv6('2606:4700:4700::1111'); // false
SSRF-safe drop-in replacement for the global `fetch`. Validates the initial URL and every redirect destination against `isSafeUrl` before the request is made. Throws an `Err(403)` if a URL is deemed unsafe.

`init` accepts all standard `RequestInit` options plus:
- `safeUrl` (`boolean`, default `true`) — set to `false` to skip SSRF validation (e.g. for trusted internal endpoints).
- `safeUrl` (`boolean`, default `true`) — set to `false` to skip all SSRF validation (e.g. for fully trusted internal endpoints).
- `safeUrlAllow` (`string[]`, default `[]`) — list of origins (e.g. `"http://10.0.0.5:8080"`) or bare hostnames (e.g. `"10.0.0.5"`) that bypass SSRF checks while all other URLs remain validated. Use this instead of `safeUrl: false` when only specific private-network endpoints need to be exempted.

Custom `dispatcher` options are rejected when `safeUrl` is `true` because they can bypass SSRF protection.

Expand All @@ -81,28 +93,40 @@ const user = await res.typed(Type.Object({

### `FetchInit`

TypeScript interface extending `RequestInit` with one additional field:
TypeScript interface extending `RequestInit` with additional fields:

```ts
interface FetchInit extends RequestInit {
safeUrl?: boolean; // default: true
safeUrl?: boolean; // default: true — set false to skip all SSRF checks
safeUrlAllow?: string[]; // origins or hostnames exempt from SSRF checks
}
```

### `isSafeUrl(href: string): Promise<{ safe: boolean; url?: URL; reason?: string }>`
### `isSafeUrl(href: string, opts?: SafeUrlOptions): Promise<{ safe: boolean; url?: URL; reason?: string }>`

Validates that a URL is safe to fetch from a server context. Returns an object with:
- `safe` — `true` if the URL is safe, `false` if it should be blocked
- `url` — The parsed `URL` object (when the URL could be parsed)
- `reason` — A human-readable string explaining why the URL was blocked

Checks performed:
`opts` accepts:
- `allow` (`string[]`) — origins (e.g. `"http://10.0.0.5:8080"`) or bare hostnames that are unconditionally considered safe, bypassing all checks below.

Checks performed (when not matched by `allow`):
1. URL must be parseable
2. Protocol must be `http:` or `https:`
3. Hostname must not be `localhost` or `0.0.0.0`
4. IP literal hostnames must not be in private/special-purpose ranges
5. DNS resolution results must not map to private/special-purpose IPs

### `SafeUrlOptions`

```ts
interface SafeUrlOptions {
allow?: string[]; // origins or hostnames exempt from all SSRF checks
}
```

### `isPrivateIPv4(hostname: string): boolean`

Returns `true` if the given string is a valid IPv4 address in a private or special-purpose range.
Expand Down
1 change: 1 addition & 0 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { isSafeUrl, isPrivateIPv4, isPrivateIPv6 } from './lib/safeurl.js';
export type { SafeUrlOptions } from './lib/safeurl.js';
export { default as fetch, TypedResponse } from './lib/fetch.js';
export type { FetchInit } from './lib/fetch.js';
14 changes: 11 additions & 3 deletions lib/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ const cache = new WeakMap<TSchema, ReturnType<typeof TypeCompiler.Compile>>();
export interface FetchInit extends RequestInit {
/** Set to false to disable SSRF-safe URL validation. Defaults to true. */
safeUrl?: boolean;
/**
* Explicit list of origins (e.g. `["http://internal-api:8080"]`) or bare
* hostnames (e.g. `["internal-api"]`) that are always considered safe,
* bypassing the SSRF block-list and DNS checks. Only used when `safeUrl`
* is `true` (the default). Use this to allow known private-network
* endpoints such as the CloudTAK API when it runs inside a VPC.
*/
safeUrlAllow?: string[];
}

export class TypedResponse extends Response {
Expand Down Expand Up @@ -66,7 +74,7 @@ export default async function (
input: RequestInfo,
init?: FetchInit,
): Promise<TypedResponse> {
const { safeUrl = true, ...fetchInit } = init ?? {};
const { safeUrl = true, safeUrlAllow: allow, ...fetchInit } = init ?? {};

if (safeUrl) {
// Reject custom dispatchers: they can route requests to arbitrary internal
Expand All @@ -76,7 +84,7 @@ export default async function (
}

// Validate the initial URL.
const check = await isSafeUrl(extractHref(input));
const check = await isSafeUrl(extractHref(input), { allow });
if (!check.safe) {
throw new Err(403, null, `Unsafe URL: ${check.reason}`);
}
Expand Down Expand Up @@ -111,7 +119,7 @@ export default async function (

// Resolve relative Location headers against the current request URL.
const resolved = new URL(location, extractHref(currentInput)).href;
const locationCheck = await isSafeUrl(resolved);
const locationCheck = await isSafeUrl(resolved, { allow });
if (!locationCheck.safe) {
throw new Err(403, null, `Unsafe redirect URL: ${locationCheck.reason}`);
}
Expand Down
37 changes: 36 additions & 1 deletion lib/safeurl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,49 @@ export function isPrivateIPv6(address: string): boolean {
return isBlockedIP(addr);
}

export async function isSafeUrl(href: string): Promise<{ safe: boolean; url?: URL; reason?: string }> {
/**
* Options for `isSafeUrl`.
*/
export interface SafeUrlOptions {
/**
* Explicit list of origins (e.g. `["http://internal-api:8080"]`) or bare
* hostnames (e.g. `["internal-api"]`) that are always considered safe,
* bypassing the SSRF block-list and DNS checks. Use this to allow known
* private-network endpoints that the caller has already authorised.
*/
allow?: string[];
}

export async function isSafeUrl(href: string, opts: SafeUrlOptions = {}): Promise<{ safe: boolean; url?: URL; reason?: string }> {
let url: URL;
try {
url = new URL(href);
} catch {
return { safe: false, reason: `invalid URL: ${href}` };
}

// Explicit allow-list check: if the URL's origin or hostname exactly matches
// any entry in `allow`, skip all SSRF checks and return safe immediately.
if (opts.allow && opts.allow.length > 0) {
for (const entry of opts.allow) {
// Treat entries that look like origins (contain ://) as full-origin matches,
// otherwise treat them as bare hostname matches.
if (entry.includes('://')) {
let allowedOrigin: string;
try {
allowedOrigin = new URL(entry).origin;
} catch {
continue;
}
if (url.origin === allowedOrigin) return { safe: true, url };
} else {
const allowedHost = entry.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
const urlHost = url.hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
if (urlHost === allowedHost) return { safe: true, url };
}
}
}

if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return { safe: false, url, reason: `unsupported protocol: ${url.protocol}` };
}
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@tak-ps/node-safeurl",
"type": "module",
"version": "1.2.0",
"version": "1.3.0",
"description": "SSRF-safe URL validation library for Node.js",
"author": "Nick Ingalls <nick@ingalls.ca>",
"types": "./dist/index.d.ts",
Expand Down
Loading