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.1.0 - 2026-06-06

- :rocket: Implement Type Safe fetch wrapper to prevent SSRF vulnerabilities in HTTP requests

### v1.0.1 - 2025-06-05

- :bug: Fix types path for built output
Expand Down
52 changes: 49 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ Lightweight TypeScript library for validating URLs to prevent Server-Side Reques

## About

`node-safeurl` provides three main exports:
`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.
- **`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.
- **`isPrivateIPv4(address)`** — Synchronous check for private/special-purpose IPv4 addresses.
- **`isPrivateIPv6(address)`** — Synchronous check for private/special-purpose IPv6 addresses.
Expand All @@ -23,9 +26,17 @@ npm install @tak-ps/node-safeurl
## Usage

```js
import { isSafeUrl, isPrivateIPv4, isPrivateIPv6 } from '@tak-ps/node-safeurl';
import fetch, { isSafeUrl, isPrivateIPv4, isPrivateIPv6 } from '@tak-ps/node-safeurl';
import { Type } from '@sinclair/typebox';

// Validate a URL before fetching
// SSRF-safe fetch — validates the URL and every redirect hop automatically
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
const internal = await fetch('http://localhost:3000/health', { safeUrl: false });

// Validate a URL manually before fetching
const result = await isSafeUrl('https://example.com/api');
if (result.safe) {
// Safe to fetch
Expand All @@ -43,6 +54,41 @@ isPrivateIPv6('2606:4700:4700::1111'); // false

## API

### `fetch(input, init?): Promise<TypedResponse>`

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).

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

### `TypedResponse`

Subclass of `Response` returned by `fetch`. Adds:

#### `.typed<T>(schema: TSchema): Promise<Static<T>>`

Parses the response body as JSON and validates it against a [TypeBox](https://github.com/sinclairzx81/typebox) schema. Throws `Err(500)` if validation fails.

```js
const res = await fetch('https://api.example.com/user/1');
const user = await res.typed(Type.Object({
id: Type.Number(),
name: Type.String(),
}));
```

### `FetchInit`

TypeScript interface extending `RequestInit` with one additional field:

```ts
interface FetchInit extends RequestInit {
safeUrl?: boolean; // default: true
}
```

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

Validates that a URL is safe to fetch from a server context. Returns an object with:
Expand Down
2 changes: 2 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export { isSafeUrl, isPrivateIPv4, isPrivateIPv6 } from './lib/safeurl.js';
export { default as fetch, TypedResponse } from './lib/fetch.js';
export type { FetchInit } from './lib/fetch.js';
Comment on lines 1 to +3
135 changes: 135 additions & 0 deletions lib/fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import Err from '@openaddresses/batch-error';
import type { Static, TSchema, TUnknown } from '@sinclair/typebox';
import { TypeCompiler } from '@sinclair/typebox/compiler';
import { fetch, Response } from 'undici';
import type { RequestInfo, RequestInit } from 'undici';
import { isSafeUrl } from './safeurl.js';

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;
}

export class TypedResponse extends Response {
constructor(response: Response) {
super(response.body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
const originalUrl = response.url;
Object.defineProperty(this, 'url', {
get: () => originalUrl,
enumerable: true,
configurable: true,
});
}

typed<T extends TSchema>(type: T): Promise<Static<T>>;

async typed<T extends TSchema = TUnknown>(type: T): Promise<Static<T>> {
Comment thread
ingalls marked this conversation as resolved.
const body = await this.json();

const cached = cache.get(type);

let typeChecker;

if (cached) {
typeChecker = cached;
} else {
typeChecker = TypeCompiler.Compile(type);
cache.set(type, typeChecker);
}

const result = typeChecker.Check(body);

if (result) return body;

const errors = typeChecker.Errors(body);
const firstError = errors[Symbol.iterator]().next().value ?? null;
throw new Err(500, null, `Internal Validation Error: ${JSON.stringify(firstError ?? null)}`);
}
}

const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
const MAX_REDIRECTS = 10;

function extractHref(input: RequestInfo): string {
if (typeof input === 'string') return input;
if (input instanceof URL) return input.href;
return (input as { url?: string }).url ?? input.toString();
}

export default async function (
input: RequestInfo,
init?: FetchInit,
): Promise<TypedResponse> {
const { safeUrl = true, ...fetchInit } = init ?? {};
Comment thread
ingalls marked this conversation as resolved.
Comment thread
ingalls marked this conversation as resolved.

if (safeUrl) {
// Reject custom dispatchers: they can route requests to arbitrary internal
// services regardless of what URL was validated, bypassing SSRF protection.
if ('dispatcher' in fetchInit) {
throw new Err(403, null, 'Custom dispatchers are not permitted when safeUrl is enabled');
}

// Validate the initial URL.
const check = await isSafeUrl(extractHref(input));
if (!check.safe) {
throw new Err(403, null, `Unsafe URL: ${check.reason}`);
}

// Follow redirects manually so each hop is re-validated against isSafeUrl,
// preventing SSRF via a public URL that 30x-redirects to an internal address.
const callerRedirect = fetchInit.redirect;
fetchInit.redirect = 'manual';

let currentInput: RequestInfo = input;
let hops = 0;

while (true) {
const response = await fetch(currentInput, fetchInit);

if (!REDIRECT_STATUSES.has(response.status) || callerRedirect === 'manual') {
return new TypedResponse(response);
}

if (callerRedirect === 'error') {
throw new Err(400, null, 'Redirects are not allowed');
}

if (++hops > MAX_REDIRECTS) {
throw new Err(400, null, 'Too many redirects');
}

const location = response.headers.get('location');
if (!location) {
return new TypedResponse(response);
}

// Resolve relative Location headers against the current request URL.
const resolved = new URL(location, extractHref(currentInput)).href;
const locationCheck = await isSafeUrl(resolved);
if (!locationCheck.safe) {
throw new Err(403, null, `Unsafe redirect URL: ${locationCheck.reason}`);
}

// For 303, switch to GET for non-GET/HEAD methods. For 301/302, switch to GET only for POST.
const method = (fetchInit.method ?? 'GET').toUpperCase();
const switchToGet =
(response.status === 303 && method !== 'GET' && method !== 'HEAD') ||
((response.status === 301 || response.status === 302) && method === 'POST');

if (switchToGet) {
fetchInit.method = 'GET';
fetchInit.body = undefined;
}

currentInput = resolved;
}
}
Comment thread
ingalls marked this conversation as resolved.

return new TypedResponse(await fetch(input, fetchInit));
}
8 changes: 5 additions & 3 deletions lib/safeurl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,11 @@ export async function isSafeUrl(href: string): Promise<{ safe: boolean; url?: UR
return { safe: false, url, reason: `unsupported protocol: ${url.protocol}` };
}

// Strip IPv6 brackets and any trailing dot (trailing dot is valid per DNS but bypasses
// literal hostname checks — e.g. "localhost." has the same meaning as "localhost").
const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
// Strip IPv6 brackets, any trailing dot, and any zone ID (e.g. %eth0 or percent-encoded
// %25eth0). Zone IDs are valid in IPv6 link-local syntax but ipaddr.js cannot parse the
// percent-encoded form, causing isBlockedIP() to return false and silently allowing
// addresses like fe80::1%25eth0 through the block-list check.
const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/g, '').split('%')[0];

// Block known-bad hostname literals, including all subdomains of localhost
// (modern OS resolvers route *.localhost to 127.0.0.1).
Expand Down
Loading
Loading