From c6e15098a2333445e77c83a4246c89b1854eb3cb Mon Sep 17 00:00:00 2001 From: ingalls Date: Sat, 6 Jun 2026 18:01:01 -0600 Subject: [PATCH 1/4] Allow List --- index.ts | 1 + lib/fetch.ts | 14 +++++++++++--- lib/safeurl.ts | 37 ++++++++++++++++++++++++++++++++++++- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/index.ts b/index.ts index 6bad951..819525f 100644 --- a/index.ts +++ b/index.ts @@ -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'; diff --git a/lib/fetch.ts b/lib/fetch.ts index 8b8480c..fc544b2 100644 --- a/lib/fetch.ts +++ b/lib/fetch.ts @@ -10,6 +10,14 @@ const cache = new WeakMap>(); 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 { @@ -66,7 +74,7 @@ export default async function ( input: RequestInfo, init?: FetchInit, ): Promise { - const { safeUrl = true, ...fetchInit } = init ?? {}; + const { safeUrl = true, safeUrlAllow: allow, ...fetchInit } = init ?? {}; if (safeUrl) { // Reject custom dispatchers: they can route requests to arbitrary internal @@ -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}`); } @@ -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}`); } diff --git a/lib/safeurl.ts b/lib/safeurl.ts index 456dc4b..c8e8767 100644 --- a/lib/safeurl.ts +++ b/lib/safeurl.ts @@ -71,7 +71,20 @@ 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); @@ -79,6 +92,28 @@ export async function isSafeUrl(href: string): Promise<{ safe: boolean; url?: UR 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}` }; } From fa50124db5048838e070cbaf114f34c79c5d37f6 Mon Sep 17 00:00:00 2001 From: ingalls Date: Sat, 6 Jun 2026 18:02:24 -0600 Subject: [PATCH 2/4] Update README --- README.md | 42 +++++++++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 6e5b199..86926ca 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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) { @@ -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 @@ -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. @@ -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. From 88228145f634b2f3196e919103e1baa5942e010e Mon Sep 17 00:00:00 2001 From: ingalls Date: Sat, 6 Jun 2026 18:04:19 -0600 Subject: [PATCH 3/4] Update CHANGELOG --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ff799e..8f2e748 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 From 695f15de05eb0c6ca70c1366ee4755c8729ae826 Mon Sep 17 00:00:00 2001 From: ingalls Date: Sat, 6 Jun 2026 18:04:22 -0600 Subject: [PATCH 4/4] 1.3.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6e0c8bb..c10725e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tak-ps/node-safeurl", - "version": "1.2.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tak-ps/node-safeurl", - "version": "1.2.0", + "version": "1.3.0", "license": "MIT", "dependencies": { "@microsoft/antissrf": "^1.0.0", diff --git a/package.json b/package.json index 87047a0..9d43d65 100644 --- a/package.json +++ b/package.json @@ -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 ", "types": "./dist/index.d.ts",