From d6f23ec2d713542e471aabae25deadf24d698a6c Mon Sep 17 00:00:00 2001 From: Andrew Khoury Date: Fri, 29 May 2026 11:32:27 -0700 Subject: [PATCH 1/3] fix(security): reject userinfo in is_url_safe to prevent SSRF bypass Stripping '@' from the raw URL string before parsing corrupts the host/userinfo boundary, allowing payloads like http://evil.com@127.0.0.1/ to pass all internal-IP checks. Fix: remove remove_at_symbol_in_string from is_url_safe and instead reject any URL whose parsed .username or .password is non-empty. Also fix tsconfig.json so the project builds cleanly with TS 6. Fixes #97 --- src/helpers.ts | 6 +++++- tsconfig.json | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/helpers.ts b/src/helpers.ts index 1e4d1e8..4aa75a2 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -507,12 +507,16 @@ export async function is_url_safe(url: string): Promise { u = replace_backslash_with_slash_in_string(u); u = replace_two_slashes_url_to_normal_url(u); - u = remove_at_symbol_in_string(u); const schema = normalize_schema(u); if (!is_proto_safe(schema)) return false; const parsed = new URL(u); + + // Reject userinfo — credentials before '@' are never needed for safe outbound + // requests and are a classic SSRF bypass vector (e.g. http://evil@127.0.0.1/) + if (parsed.username !== "" || parsed.password !== "") return false; + const hostname = parsed.hostname.replace(/^\[|\]$/g, ""); // IPv4 validation diff --git a/tsconfig.json b/tsconfig.json index 9251321..81efffd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,10 @@ "strict": true, "declaration": true, "outDir": "dist", - "esModuleInterop": true + "rootDir": "src", + "esModuleInterop": true, + "ignoreDeprecations": "6.0", + "types": ["node"] }, "include": ["src"] } From 83bee355bab08d70ec77170893527ed1e582250d Mon Sep 17 00:00:00 2001 From: Andrew Khoury Date: Fri, 29 May 2026 12:30:58 -0700 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20address=20review=20comments=20?= =?UTF-8?q?=E2=80=94=20rebuild=20dist,=20fix=20is=5Fredirect=5Fsafe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Commit rebuilt dist/ so the shipped entrypoints contain the fix (dist/helpers.js was still carrying the old vulnerable is_url_safe) - Remove remove_at_symbol_in_string from is_redirect_safe (same root cause as is_url_safe — strips '@' before parsing, corrupting host) - Add userinfo rejection (username/password check) to is_redirect_safe for both the initial URL and each redirect target Addresses review comments on #97 --- dist/helpers.js | 10 ++++++++-- dist/utils.js | 1 + src/helpers.ts | 3 ++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/dist/helpers.js b/dist/helpers.js index f1b0fcc..74346c9 100644 --- a/dist/helpers.js +++ b/dist/helpers.js @@ -386,8 +386,9 @@ function is_proto_safe(url) { async function is_redirect_safe(url) { try { let normalized = replace_backslash_with_slash_in_string(url); - normalized = remove_at_symbol_in_string(normalized); let current = new URL(normalized); + if (current.username !== "" || current.password !== "") + return false; const MAX_REDIRECTS = 5; for (let i = 0; i < MAX_REDIRECTS; i++) { if (!is_proto_safe(current.protocol)) @@ -406,6 +407,8 @@ async function is_redirect_safe(url) { } try { current = new URL(loc, current.toString()); + if (current.username !== "" || current.password !== "") + return false; } catch { return false; @@ -427,11 +430,14 @@ async function is_url_safe(url) { let u = normalize_unicode(url); u = replace_backslash_with_slash_in_string(u); u = replace_two_slashes_url_to_normal_url(u); - u = remove_at_symbol_in_string(u); const schema = normalize_schema(u); if (!is_proto_safe(schema)) return false; const parsed = new URL(u); + // Reject userinfo — credentials before '@' are never needed for safe outbound + // requests and are a classic SSRF bypass vector (e.g. http://evil@127.0.0.1/) + if (parsed.username !== "" || parsed.password !== "") + return false; const hostname = parsed.hostname.replace(/^\[|\]$/g, ""); // IPv4 validation if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) { diff --git a/dist/utils.js b/dist/utils.js index 19b4f04..bdf12e5 100644 --- a/dist/utils.js +++ b/dist/utils.js @@ -1,4 +1,5 @@ "use strict"; +//! A module contain all utilities for SSRF mitigation Object.defineProperty(exports, "__esModule", { value: true }); exports.is_url_safe_debug = exports.is_url_safe = exports.replace_two_slashes_url_to_normal_url = exports.replace_backslash_with_slash_in_string = exports.remove_at_symbol_in_string = exports.octal_ip_to_normal_ip = exports.normalize_unicode = exports.normalize_schema = exports.normalize_ipv4 = exports.is_redirect_safe = exports.is_range_not_internal = exports.is_proto_safe = exports.is_hostname_resolve_to_internal_ip = exports.hex_ip_to_normal_ip = exports.decimal_ip_to_normal_ip = exports.bin_ip_to_normal_ip = exports.is_ipv6 = void 0; var helpers_1 = require("./helpers"); diff --git a/src/helpers.ts b/src/helpers.ts index 4aa75a2..995b1c9 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -451,9 +451,9 @@ export function is_proto_safe(url: string): boolean { export async function is_redirect_safe(url: string): Promise { try { let normalized = replace_backslash_with_slash_in_string(url); - normalized = remove_at_symbol_in_string(normalized); let current = new URL(normalized); + if (current.username !== "" || current.password !== "") return false; const MAX_REDIRECTS = 5; for (let i = 0; i < MAX_REDIRECTS; i++) { @@ -474,6 +474,7 @@ export async function is_redirect_safe(url: string): Promise { try { current = new URL(loc, current.toString()); + if (current.username !== "" || current.password !== "") return false; } catch { return false; } From 3958c4a1506e73581640356dab344ea370fb171d Mon Sep 17 00:00:00 2001 From: Andrew Khoury Date: Fri, 29 May 2026 12:38:06 -0700 Subject: [PATCH 3/3] refactor: extract is_parsed_url_safe to eliminate duplicated checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All proto, userinfo, and hostname validation is now in one place: is_parsed_url_safe(). Both is_url_safe and is_redirect_safe delegate to it, removing the repeated username/password checks and making redirect-target validation consistent with the primary check. Also removes the now-dead post-parse IPv4 normalization branch — WHATWG URL already normalizes the hostname before we inspect it, so normalize_ipv4 on parsed.hostname was a no-op. --- dist/helpers.js | 58 +++++++++++++++---------------------------------- src/helpers.ts | 58 ++++++++++++++----------------------------------- 2 files changed, 33 insertions(+), 83 deletions(-) diff --git a/dist/helpers.js b/dist/helpers.js index 74346c9..61f9055 100644 --- a/dist/helpers.js +++ b/dist/helpers.js @@ -383,17 +383,23 @@ function is_proto_safe(url) { return true; return false; } +async function is_parsed_url_safe(parsed) { + if (!is_proto_safe(parsed.protocol)) + return false; + if (parsed.username !== "" || parsed.password !== "") + return false; + // WHATWG URL includes brackets in .hostname for IPv6 (e.g. "[::1]"); strip them + const hostname = parsed.hostname.replace(/^\[|\]$/g, ""); + if (await is_hostname_resolve_to_internal_ip(hostname)) + return false; + return true; +} async function is_redirect_safe(url) { try { - let normalized = replace_backslash_with_slash_in_string(url); - let current = new URL(normalized); - if (current.username !== "" || current.password !== "") - return false; + let current = new URL(replace_backslash_with_slash_in_string(url)); const MAX_REDIRECTS = 5; for (let i = 0; i < MAX_REDIRECTS; i++) { - if (!is_proto_safe(current.protocol)) - return false; - if (await is_hostname_resolve_to_internal_ip(current.hostname)) + if (!await is_parsed_url_safe(current)) return false; const res = await got(current.toString(), { method: "HEAD", @@ -402,17 +408,9 @@ async function is_redirect_safe(url) { timeout: { request: 3000 } }); const loc = res.headers.location; - if (!loc) { + if (!loc) return true; - } - try { - current = new URL(loc, current.toString()); - if (current.username !== "" || current.password !== "") - return false; - } - catch { - return false; - } + current = new URL(loc, current.toString()); } return false; } @@ -430,33 +428,11 @@ async function is_url_safe(url) { let u = normalize_unicode(url); u = replace_backslash_with_slash_in_string(u); u = replace_two_slashes_url_to_normal_url(u); - const schema = normalize_schema(u); - if (!is_proto_safe(schema)) - return false; const parsed = new URL(u); - // Reject userinfo — credentials before '@' are never needed for safe outbound - // requests and are a classic SSRF bypass vector (e.g. http://evil@127.0.0.1/) - if (parsed.username !== "" || parsed.password !== "") - return false; - const hostname = parsed.hostname.replace(/^\[|\]$/g, ""); - // IPv4 validation - if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) { - try { - normalize_ipv4(hostname); - } - catch { - return false; - } - } - if (is_ipv6(hostname)) { - if (is_ip_internal(hostname)) - return false; - } - if (await is_hostname_resolve_to_internal_ip(hostname)) + if (!await is_parsed_url_safe(parsed)) return false; if (process.env.DSSRF_CHECK_REDIRECTS === "1") { - const redirectSafe = await is_redirect_safe(u); - if (!redirectSafe) + if (!await is_redirect_safe(u)) return false; } return true; diff --git a/src/helpers.ts b/src/helpers.ts index 995b1c9..c3fd044 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -448,17 +448,22 @@ export function is_proto_safe(url: string): boolean { +async function is_parsed_url_safe(parsed: URL): Promise { + if (!is_proto_safe(parsed.protocol)) return false; + if (parsed.username !== "" || parsed.password !== "") return false; + // WHATWG URL includes brackets in .hostname for IPv6 (e.g. "[::1]"); strip them + const hostname = parsed.hostname.replace(/^\[|\]$/g, ""); + if (await is_hostname_resolve_to_internal_ip(hostname)) return false; + return true; +} + export async function is_redirect_safe(url: string): Promise { try { - let normalized = replace_backslash_with_slash_in_string(url); - - let current = new URL(normalized); - if (current.username !== "" || current.password !== "") return false; + let current = new URL(replace_backslash_with_slash_in_string(url)); const MAX_REDIRECTS = 5; for (let i = 0; i < MAX_REDIRECTS; i++) { - if (!is_proto_safe(current.protocol)) return false; - if (await is_hostname_resolve_to_internal_ip(current.hostname)) return false; + if (!await is_parsed_url_safe(current)) return false; const res = await got(current.toString(), { method: "HEAD", @@ -468,16 +473,9 @@ export async function is_redirect_safe(url: string): Promise { }); const loc = res.headers.location; - if (!loc) { - return true; - } - - try { - current = new URL(loc, current.toString()); - if (current.username !== "" || current.password !== "") return false; - } catch { - return false; - } + if (!loc) return true; + + current = new URL(loc, current.toString()); } return false; @@ -505,39 +503,15 @@ export function normalize_unicode(input: string): string { export async function is_url_safe(url: string): Promise { try { let u = normalize_unicode(url); - u = replace_backslash_with_slash_in_string(u); u = replace_two_slashes_url_to_normal_url(u); - const schema = normalize_schema(u); - if (!is_proto_safe(schema)) return false; - const parsed = new URL(u); - // Reject userinfo — credentials before '@' are never needed for safe outbound - // requests and are a classic SSRF bypass vector (e.g. http://evil@127.0.0.1/) - if (parsed.username !== "" || parsed.password !== "") return false; - - const hostname = parsed.hostname.replace(/^\[|\]$/g, ""); - - // IPv4 validation - if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) { - try { - normalize_ipv4(hostname); - } catch { - return false; - } - } - - if (is_ipv6(hostname)) { - if (is_ip_internal(hostname)) return false; - } - - if (await is_hostname_resolve_to_internal_ip(hostname)) return false; + if (!await is_parsed_url_safe(parsed)) return false; if (process.env.DSSRF_CHECK_REDIRECTS === "1") { - const redirectSafe = await is_redirect_safe(u); - if (!redirectSafe) return false; + if (!await is_redirect_safe(u)) return false; } return true;