Skip to content
This repository was archived by the owner on Jun 12, 2026. It is now read-only.
Open
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
14 changes: 9 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { URL } from "url";
import { Parser } from "htmlparser2";
import nodeFetch from "node-fetch";
import UnexpectedError from "./unexpectedError";
import { schema, keys } from "./schema";
import { Metadata, Opts } from "./types";
import { decode as he_decode } from "he";
import { decode as iconv_decode } from "iconv-lite";
import { safeFetch } from "./ssrfGuard";

type ParserContext = {
isHtml?: boolean;
Expand Down Expand Up @@ -48,11 +48,12 @@ function unfurl(url: string, opts?: Opts): Promise<Metadata> {
async function getPage(url: string, opts: Opts) {
const res = await (opts.fetch
? opts.fetch(url)
: nodeFetch(new URL(url), {
: safeFetch(url, {
headers: opts.headers,
size: opts.size,
follow: opts.follow,
timeout: opts.timeout,
allowPrivateIPs: opts.allowPrivateIPs,
}));

const buf = Buffer.from(await res.arrayBuffer());
Expand Down Expand Up @@ -122,23 +123,26 @@ async function getPage(url: string, opts: Opts) {
return buf.toString();
}

function getRemoteMetadata(url: string, { fetch = nodeFetch }: Opts) {
function getRemoteMetadata(url: string, { fetch, allowPrivateIPs }: Opts) {
return async function ({ oembed, metadata }) {
if (!oembed) {
return metadata;
}

const target = new URL(he_decode(oembed.href), url);
const doFetch = fetch
? (u: string) => fetch(u)
: (u: string) => safeFetch(u, { allowPrivateIPs });

let res = await fetch(target.href);
let res = await doFetch(target.href);
let contentType = res.headers.get("Content-Type");
const status = res.status;

if (status === 403 && target.protocol === "http:") {
// try again using HTTPS
target.protocol = "https:";

res = await fetch(target.href);
res = await doFetch(target.href);
contentType = res.headers.get("Content-Type");
}

Expand Down
190 changes: 190 additions & 0 deletions src/ssrfGuard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/**
* SSRF guard — resolves a URL's hostname and rejects requests targeting
* private, loopback, link-local, or otherwise unsafe IP ranges.
*
* Used to prevent attackers from coercing unfurl into making HTTP requests
* to internal infrastructure (cloud metadata endpoints, internal APIs,
* private network services, etc).
*/

import { URL } from "url";
import nodeFetch, { RequestInit, Response } from "node-fetch";
import { promises as dns } from "dns";
import { isIP } from "net";

export class SSRFError extends Error {
constructor(message: string) {
super(message);
this.name = "SSRFError";
}
}

const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);

/**
* Returns true if the given IP address is in a range that should not be
* reachable from a public-input URL fetcher. Covers IPv4 and IPv6.
*/
export function isPrivateOrReservedIP(ip: string): boolean {
const family = isIP(ip);
if (family === 0) return false;
ip = ip.toLowerCase();

if (family === 4) {
const [a, b] = ip.split(".").map((p) => parseInt(p, 10));

// 0.0.0.0/8 — current network
if (a === 0) return true;
// 10.0.0.0/8 — private
if (a === 10) return true;
// 127.0.0.0/8 — loopback
if (a === 127) return true;
// 169.254.0.0/16 — link-local (includes cloud metadata 169.254.169.254)
if (a === 169 && b === 254) return true;
// 172.16.0.0/12 — private
if (a === 172 && b >= 16 && b <= 31) return true;
// 192.0.0.0/24, 192.0.2.0/24 — reserved/documentation
if (a === 192 && b === 0) return true;
// 192.168.0.0/16 — private
if (a === 192 && b === 168) return true;
// 198.18.0.0/15 — benchmarking
if (a === 198 && (b === 18 || b === 19)) return true;
// 198.51.100.0/24 — documentation
if (a === 198 && b === 51) return true;
// 203.0.113.0/24 — documentation
if (a === 203 && b === 0) return true;
// 224.0.0.0/4 — multicast
if (a >= 224 && a <= 239) return true;
// 240.0.0.0/4 — reserved (includes 255.255.255.255 broadcast)
if (a >= 240) return true;

return false;
}

// IPv6
// Loopback ::1
if (ip === "::1") return true;
// Unspecified ::
if (ip === "::") return true;
// IPv4-mapped IPv6 (::ffff:x.x.x.x) — recurse on the embedded v4
const v4MappedMatch = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (v4MappedMatch) return isPrivateOrReservedIP(v4MappedMatch[1]);
// Unique local fc00::/7
if (ip.startsWith("fc") || ip.startsWith("fd")) return true;
// Link-local fe80::/10
if (
ip.startsWith("fe8") ||
ip.startsWith("fe9") ||
ip.startsWith("fea") ||
ip.startsWith("feb")
)
return true;
// Multicast ff00::/8
if (ip.startsWith("ff")) return true;

return false;
}

/**
* Validates that a URL is safe to fetch from a public-input link-preview
* context: must be http(s) and must not resolve to a private/reserved IP.
*
* Throws SSRFError if the URL fails any check. Resolves silently if safe.
*/
export async function assertSafeURL(
rawUrl: string,
allowPrivateIPs = false
): Promise<void> {
if (allowPrivateIPs) return;
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
throw new SSRFError(`Invalid URL: ${rawUrl}`);
}

if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) {
throw new SSRFError(`Disallowed protocol: ${parsed.protocol}`);
}

const hostname = parsed.hostname;
if (!hostname) {
throw new SSRFError("URL has no hostname");
}

// If the hostname is already a literal IP, validate it directly.
if (isIP(hostname) !== 0) {
if (isPrivateOrReservedIP(hostname)) {
throw new SSRFError(`Disallowed destination IP: ${hostname}`);
}
return;
}

// Otherwise resolve all addresses and reject if ANY resolve to a
// forbidden range. (Defense against DNS responses with mixed records.)
let addresses: { address: string; family: number }[];
try {
addresses = await dns.lookup(hostname, { all: true });
} catch (err) {
throw new SSRFError(
`Failed to resolve ${hostname}: ${(err as Error).message}`
);
}

if (addresses.length === 0) {
throw new SSRFError(`No addresses resolved for ${hostname}`);
}

for (const { address } of addresses) {
if (isPrivateOrReservedIP(address)) {
throw new SSRFError(
`Hostname ${hostname} resolves to disallowed IP ${address}`
);
}
}
}

/**
* Wraps node-fetch with manual redirect handling so that each redirect
* target is re-validated against the SSRF guard. node-fetch's automatic
* redirect-following bypasses any one-shot pre-fetch validation, so we
* have to walk the chain ourselves.
*/
export async function safeFetch(
initialUrl: string,
init: RequestInit & { follow?: number; allowPrivateIPs?: boolean } = {}
): Promise<Response> {
const maxRedirects = typeof init.follow === "number" ? init.follow : 20;
const allowPrivateIPs = init.allowPrivateIPs === true;

// Strip our custom keys before passing to node-fetch.
delete init.follow;
delete init.allowPrivateIPs;

let currentUrl = initialUrl;

for (let hop = 0; hop <= maxRedirects; hop++) {
await assertSafeURL(currentUrl, allowPrivateIPs);

const res = await nodeFetch(currentUrl, {
...init,
redirect: "manual",
});

// Not a redirect — return as-is.
if (res.status < 300 || res.status >= 400) {
return res;
}

const location = res.headers.get("location");
if (!location) {
// Redirect status with no Location header — return what we got.
return res;
}

// Resolve relative redirects against the URL that produced them.
currentUrl = new URL(location, currentUrl).href;
}

throw new SSRFError(`Too many redirects (>${maxRedirects})`);
}
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { HeadersInit } from "node-fetch";
export type Opts = {
allowPrivateIPs?: boolean;
/** support retreiving oembed metadata */
oembed?: boolean;
/** req/res timeout in ms, it resets on redirect. 0 to disable (OS limit applies) */
Expand Down
21 changes: 16 additions & 5 deletions test/basic/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ test("should handle content which is escaped badly", async () => {
"Content-Type": "text/html",
});

const result = await unfurl("http://localhost/html/double-escaped-edge-case");
const result = await unfurl(
"http://localhost/html/double-escaped-edge-case",
{ allowPrivateIPs: true }
);

expect(result.description).toEqual('"');
});
Expand All @@ -20,7 +23,9 @@ test("should detect title, description, keywords and canonical URL", async () =>
"Content-Type": "text/html",
});

const result = await unfurl("http://localhost/html/basic");
const result = await unfurl("http://localhost/html/basic", {
allowPrivateIPs: true,
});

const expected = {
favicon: "http://localhost/favicon.ico",
Expand All @@ -42,7 +47,9 @@ test("should detect title, description, keywords and canonical URL even when the
"Content-Type": "text/html",
});

const result = await unfurl("http://localhost/html/basic-body");
const result = await unfurl("http://localhost/html/basic-body", {
allowPrivateIPs: true,
});

const expected = {
favicon: "http://localhost/favicon.ico",
Expand All @@ -62,7 +69,9 @@ test("should detect last dupe of title, description and keywords", async () => {
"Content-Type": "text/html",
});

const result = await unfurl("http://localhost/html/basic-duplicates");
const result = await unfurl("http://localhost/html/basic-duplicates", {
allowPrivateIPs: true,
});

const expected = {
favicon: "http://localhost/favicon.ico",
Expand All @@ -81,7 +90,9 @@ test("should detect last dupe of title, description and keywords", async () => {
"Content-Type": "text/html",
});

const result = await unfurl("http://localhost/html/keyword-edge-cases");
const result = await unfurl("http://localhost/html/keyword-edge-cases", {
allowPrivateIPs: true,
});

const expected = {
favicon: "http://localhost/favicon.ico",
Expand Down
12 changes: 9 additions & 3 deletions test/encoding/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ test("should detect GB2312 charset (HTML 4) and convert to UTF-8", async () => {
"Content-Type": "text/html",
});

const result = await unfurl("http://localhost/html4/gb2312");
const result = await unfurl("http://localhost/html4/gb2312", {
allowPrivateIPs: true,
});

const expected = {
description:
Expand All @@ -28,7 +30,9 @@ test("should detect GB2312 charset (HTML 5) and convert to UTF-8", async () => {
"Content-Type": "text/html",
});

const result = await unfurl("http://localhost/html5/gb2312");
const result = await unfurl("http://localhost/html5/gb2312", {
allowPrivateIPs: true,
});

const expected = {
description:
Expand All @@ -47,7 +51,9 @@ test("should detect EUC-JP charset (HTML 5) and convert to UTF-8", async () => {
"Content-Type": "text/html",
});

const result = await unfurl("http://localhost/html5/euc-jp");
const result = await unfurl("http://localhost/html5/euc-jp", {
allowPrivateIPs: true,
});

const expected = {
description:
Expand Down
2 changes: 1 addition & 1 deletion test/general/content-type.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ test("should throw bad content type error", async () => {
"Content-Type": "image/png",
});

await unfurl("http://localhost/image");
await unfurl("http://localhost/image", { allowPrivateIPs: true });
} catch (err) {
expect(err.name).toEqual(UnexpectedError.EXPECTED_HTML.name);
expect(err.message).toEqual(UnexpectedError.EXPECTED_HTML.message);
Expand Down
1 change: 1 addition & 0 deletions test/general/options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ test("should respect oembed", async () => {

const result = await unfurl("http://localhost/html/oembed", {
oembed: false,
allowPrivateIPs: true,
});

expect(result.oEmbed).toEqual(undefined);
Expand Down
8 changes: 4 additions & 4 deletions test/general/status-code.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import UnexpectedError from "../../src/unexpectedError";
test("should throw if status code not 200", () => {
nock("http://localhost").get("/html/return-404").reply(404);

return expect(unfurl("http://localhost/html/return-404")).rejects.toThrow(
new UnexpectedError(UnexpectedError.BAD_HTTP_STATUS)
);
return expect(
unfurl("http://localhost/html/return-404", { allowPrivateIPs: true })
).rejects.toThrow(new UnexpectedError(UnexpectedError.BAD_HTTP_STATUS));
});

test("should not throw if status code is 200", async () => {
Expand All @@ -16,6 +16,6 @@ test("should not throw if status code is 200", async () => {
});

return expect(
unfurl("http://localhost/html/return-200")
unfurl("http://localhost/html/return-200", { allowPrivateIPs: true })
).resolves.toBeTruthy();
});
4 changes: 3 additions & 1 deletion test/general/url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ test("should not throw when provided non-ascii url", async () => {

let err;
try {
await unfurl("http://localhost/日本語urlってどうよ");
await unfurl("http://localhost/日本語urlってどうよ", {
allowPrivateIPs: true,
});
} catch (e) {
err = e;
} finally {
Expand Down
Loading
Loading