From a242c53d9635db105a88f7377fe81e0acc6ab29a Mon Sep 17 00:00:00 2001 From: Massoud Valipoor Date: Fri, 5 Sep 2025 23:06:32 +0200 Subject: [PATCH 1/5] add URL scheme validation in DAHR and handleWeb2ProxyRequest --- package.json | 2 +- src/features/web2/dahr/DAHR.ts | 12 +++++++++++ .../transactions/handleWeb2ProxyRequest.ts | 21 +++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 126d6dbb7..b3507cd28 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "@fastify/cors": "^9.0.1", "@fastify/swagger": "^8.15.0", "@fastify/swagger-ui": "^4.1.0", - "@kynesyslabs/demosdk": "^2.3.17", + "@kynesyslabs/demosdk": "^2.3.22", "@modelcontextprotocol/sdk": "^1.13.3", "@octokit/core": "^6.1.5", "@types/express": "^4.17.21", diff --git a/src/features/web2/dahr/DAHR.ts b/src/features/web2/dahr/DAHR.ts index a15aa0910..2da9de7cf 100644 --- a/src/features/web2/dahr/DAHR.ts +++ b/src/features/web2/dahr/DAHR.ts @@ -70,6 +70,18 @@ export class DAHR { // Make sure we have a web2Request at this point required(this._web2Request, "web2Request") + // Validate URL scheme to prevent transport crashes + try { + const parsed = new URL(url) + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error( + `Invalid URL scheme: ${parsed.protocol}. Only http(s) are allowed.`, + ) + } + } catch (e: any) { + throw new Error(`Invalid URL format: ${url}. ${e?.message || ""}`) + } + const web2Response = await this._proxy.sendHTTPRequest({ web2Request: { ...this._web2Request, diff --git a/src/libs/network/routines/transactions/handleWeb2ProxyRequest.ts b/src/libs/network/routines/transactions/handleWeb2ProxyRequest.ts index f63d1a2ad..c92080855 100644 --- a/src/libs/network/routines/transactions/handleWeb2ProxyRequest.ts +++ b/src/libs/network/routines/transactions/handleWeb2ProxyRequest.ts @@ -57,6 +57,27 @@ export async function handleWeb2ProxyRequest({ ) } + // Validate URL scheme: only http(s) are allowed + try { + const parsed = new URL(web2Request.raw.url) + if ( + parsed.protocol !== "http:" && + parsed.protocol !== "https:" + ) { + return createRPCResponse( + 400, + null, + `Invalid URL scheme: ${parsed.protocol}. Only http(s) are allowed.`, + ) + } + } catch { + return createRPCResponse( + 400, + null, + `Invalid URL format: ${web2Request.raw.url}`, + ) + } + dahr.web2Request.raw = { ...dahr.web2Request.raw, url: web2Request.raw.url, From ce64b2865db4f78aee52020c664f7b06e8cdbffd Mon Sep 17 00:00:00 2001 From: Massoud Valipoor Date: Fri, 5 Sep 2025 23:56:18 +0200 Subject: [PATCH 2/5] refactor: implement centralized URL validation and normalization in DAHR and handleWeb2ProxyRequest --- src/features/web2/dahr/DAHR.ts | 17 ++++------- src/features/web2/validator.ts | 30 +++++++++++++++++++ .../transactions/handleWeb2ProxyRequest.ts | 29 +++++------------- 3 files changed, 44 insertions(+), 32 deletions(-) create mode 100644 src/features/web2/validator.ts diff --git a/src/features/web2/dahr/DAHR.ts b/src/features/web2/dahr/DAHR.ts index 2da9de7cf..bfa9dc73f 100644 --- a/src/features/web2/dahr/DAHR.ts +++ b/src/features/web2/dahr/DAHR.ts @@ -8,6 +8,7 @@ import { ProxyFactory } from "src/features/web2/proxy/ProxyFactory" import required from "src/utilities/required" import { generateUniqueId } from "src/utilities/generateUniqueId" import { EnumWeb2Actions } from "@kynesyslabs/demosdk/types" +import { validateAndNormalizeHttpUrl } from "src/features/web2/validator" /** * DAHR - Data Agnostic HTTPS Relay, class that handles the Web2 request and proxy process. @@ -70,16 +71,10 @@ export class DAHR { // Make sure we have a web2Request at this point required(this._web2Request, "web2Request") - // Validate URL scheme to prevent transport crashes - try { - const parsed = new URL(url) - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new Error( - `Invalid URL scheme: ${parsed.protocol}. Only http(s) are allowed.`, - ) - } - } catch (e: any) { - throw new Error(`Invalid URL format: ${url}. ${e?.message || ""}`) + // Validate and normalize URL without echoing sensitive details + const validation = validateAndNormalizeHttpUrl(url) + if (!validation.ok) { + throw new Error(validation.message) } const web2Response = await this._proxy.sendHTTPRequest({ @@ -88,7 +83,7 @@ export class DAHR { raw: { ...this._web2Request.raw, action: EnumWeb2Actions.START_PROXY, - url, + url: validation.normalizedUrl, }, }, targetMethod: method, diff --git a/src/features/web2/validator.ts b/src/features/web2/validator.ts new file mode 100644 index 000000000..cb6109737 --- /dev/null +++ b/src/features/web2/validator.ts @@ -0,0 +1,30 @@ +export type UrlValidationResult = + | { ok: true; normalizedUrl: string } + | { ok: false; status: 400; message: string } + +/** + * Validate and normalize a URL for DAHR. + * - Trims whitespace + * - Ensures protocol is http(s) + */ +export function validateAndNormalizeHttpUrl( + input: string, +): UrlValidationResult { + const trimmed = (input ?? "").trim() + if (!trimmed) { + return { ok: false, status: 400, message: "Invalid URL: empty value" } + } + try { + const parsed = new URL(trimmed) + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return { + ok: false, + status: 400, + message: "Invalid URL scheme. Only http(s) are allowed", + } + } + return { ok: true, normalizedUrl: parsed.toString() } + } catch { + return { ok: false, status: 400, message: "Invalid URL format" } + } +} diff --git a/src/libs/network/routines/transactions/handleWeb2ProxyRequest.ts b/src/libs/network/routines/transactions/handleWeb2ProxyRequest.ts index c92080855..de6c3280f 100644 --- a/src/libs/network/routines/transactions/handleWeb2ProxyRequest.ts +++ b/src/libs/network/routines/transactions/handleWeb2ProxyRequest.ts @@ -6,6 +6,7 @@ import { } from "@kynesyslabs/demosdk/types" import { handleWeb2 } from "src/features/web2/handleWeb2" import { DAHRFactory } from "src/features/web2/dahr/DAHRFactory" +import { validateAndNormalizeHttpUrl } from "src/features/web2/validator" type IHandleWeb2ProxyRequestStepParams = Pick< IWeb2Payload["message"], @@ -57,30 +58,16 @@ export async function handleWeb2ProxyRequest({ ) } - // Validate URL scheme: only http(s) are allowed - try { - const parsed = new URL(web2Request.raw.url) - if ( - parsed.protocol !== "http:" && - parsed.protocol !== "https:" - ) { - return createRPCResponse( - 400, - null, - `Invalid URL scheme: ${parsed.protocol}. Only http(s) are allowed.`, - ) - } - } catch { - return createRPCResponse( - 400, - null, - `Invalid URL format: ${web2Request.raw.url}`, - ) + const validation = validateAndNormalizeHttpUrl( + web2Request.raw.url, + ) + if (!validation.ok) { + return createRPCResponse(400, null, validation.message) } dahr.web2Request.raw = { ...dahr.web2Request.raw, - url: web2Request.raw.url, + url: validation.normalizedUrl, } const { method, headers } = web2Request.raw @@ -90,7 +77,7 @@ export async function handleWeb2ProxyRequest({ headers, payload, authorization, - url: web2Request.raw.url, + url: validation.normalizedUrl, }) return createRPCResponse(200, response) From bb540609e8d7a88962fcebea903eb4b01e393585 Mon Sep 17 00:00:00 2001 From: Massoud Valipoor Date: Sun, 7 Sep 2025 14:21:02 +0200 Subject: [PATCH 3/5] feat: enhance URL validation to reject embedded credentials, localhost, and loopback addresses; update error handling in DAHR and handleWeb2ProxyRequest --- package.json | 3 +- src/features/web2/dahr/DAHR.ts | 4 +- src/features/web2/validator.ts | 67 ++++++++++++++++++- .../transactions/handleWeb2ProxyRequest.ts | 6 +- 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index b3507cd28..40b909e7a 100644 --- a/package.json +++ b/package.json @@ -46,11 +46,12 @@ "typescript": "^5.8.3" }, "dependencies": { + "@aptos-labs/ts-sdk": "^4.0.0", "@cosmjs/encoding": "^0.33.1", "@fastify/cors": "^9.0.1", "@fastify/swagger": "^8.15.0", "@fastify/swagger-ui": "^4.1.0", - "@kynesyslabs/demosdk": "^2.3.22", + "@kynesyslabs/demosdk": "../sdks", "@modelcontextprotocol/sdk": "^1.13.3", "@octokit/core": "^6.1.5", "@types/express": "^4.17.21", diff --git a/src/features/web2/dahr/DAHR.ts b/src/features/web2/dahr/DAHR.ts index bfa9dc73f..81da47315 100644 --- a/src/features/web2/dahr/DAHR.ts +++ b/src/features/web2/dahr/DAHR.ts @@ -74,7 +74,9 @@ export class DAHR { // Validate and normalize URL without echoing sensitive details const validation = validateAndNormalizeHttpUrl(url) if (!validation.ok) { - throw new Error(validation.message) + const err = new Error(validation.message) + ;(err as any).status = validation.status + throw err } const web2Response = await this._proxy.sendHTTPRequest({ diff --git a/src/features/web2/validator.ts b/src/features/web2/validator.ts index cb6109737..dd5d3d5ae 100644 --- a/src/features/web2/validator.ts +++ b/src/features/web2/validator.ts @@ -6,6 +6,11 @@ export type UrlValidationResult = * Validate and normalize a URL for DAHR. * - Trims whitespace * - Ensures protocol is http(s) + * - Rejects URLs with embedded credentials (username/password) + * - Rejects URLs without a hostname + * - Rejects localhost and loopback IP addresses/hostnames (SSRF protection) + * - Lowercases host, strips default ports, and removes fragments for canonicalization + * - Redacts sensitive data in error messages (does not echo the full URL) */ export function validateAndNormalizeHttpUrl( input: string, @@ -16,6 +21,8 @@ export function validateAndNormalizeHttpUrl( } try { const parsed = new URL(trimmed) + + // 1. Ensure protocol is http(s) if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { return { ok: false, @@ -23,7 +30,65 @@ export function validateAndNormalizeHttpUrl( message: "Invalid URL scheme. Only http(s) are allowed", } } - return { ok: true, normalizedUrl: parsed.toString() } + + // 2. Reject URLs with embedded credentials (username/password) + if (parsed.username || parsed.password) { + return { + ok: false, + status: 400, + message: "Invalid URL: embedded credentials are not allowed", + } + } + + // 3. Reject URLs without a hostname + if (!parsed.hostname) { + return { + ok: false, + status: 400, + message: "Invalid URL: URL must have a hostname", + } + } + + const hostLower = parsed.hostname.toLowerCase() + + // 4. Reject localhost and loopback hostnames + if (hostLower === "localhost" || hostLower.endsWith(".localhost")) { + return { + ok: false, + status: 400, + message: "Localhost targets are not allowed", + } + } + + // 5. Basic loopback check for IPv4 and IPv6 + const isIPv6Loopback = hostLower === "::1" || hostLower === "[::1]" + const isIPv4Loopback = /^127(?:\.\d{1,3}){3}$/.test(hostLower) + if (isIPv4Loopback || isIPv6Loopback) { + return { + ok: false, + status: 400, + message: "Loopback targets are not allowed", + } + } + + // 6. Canonicalize the URL (lowercase host, strip default ports, remove fragment) + const canonicalUrlObject = new URL(parsed.toString()) + canonicalUrlObject.hostname = canonicalUrlObject.hostname.toLowerCase() + + // Strip default ports + if ( + (canonicalUrlObject.protocol === "http:" && + canonicalUrlObject.port === "80") || + (canonicalUrlObject.protocol === "https:" && + canonicalUrlObject.port === "443") + ) { + canonicalUrlObject.port = "" + } + + // Remove fragment + canonicalUrlObject.hash = "" + + return { ok: true, normalizedUrl: canonicalUrlObject.toString() } } catch { return { ok: false, status: 400, message: "Invalid URL format" } } diff --git a/src/libs/network/routines/transactions/handleWeb2ProxyRequest.ts b/src/libs/network/routines/transactions/handleWeb2ProxyRequest.ts index de6c3280f..3d9fd6125 100644 --- a/src/libs/network/routines/transactions/handleWeb2ProxyRequest.ts +++ b/src/libs/network/routines/transactions/handleWeb2ProxyRequest.ts @@ -62,7 +62,11 @@ export async function handleWeb2ProxyRequest({ web2Request.raw.url, ) if (!validation.ok) { - return createRPCResponse(400, null, validation.message) + return createRPCResponse( + validation.status, + null, + validation.message, + ) } dahr.web2Request.raw = { From 4471fa9b7c300f48c09962ac9dbe2fb89440349e Mon Sep 17 00:00:00 2001 From: Massoud Valipoor Date: Sun, 7 Sep 2025 14:59:27 +0200 Subject: [PATCH 4/5] feat: enhance URL validation and proxy server security by blocking private, link-local, and loopback addresses; update dependencies --- package.json | 3 +- src/features/web2/proxy/Proxy.ts | 75 +++++++++++++++++++++++++++++++- src/features/web2/validator.ts | 37 ++++++++++++++-- 3 files changed, 107 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 40b909e7a..b3507cd28 100644 --- a/package.json +++ b/package.json @@ -46,12 +46,11 @@ "typescript": "^5.8.3" }, "dependencies": { - "@aptos-labs/ts-sdk": "^4.0.0", "@cosmjs/encoding": "^0.33.1", "@fastify/cors": "^9.0.1", "@fastify/swagger": "^8.15.0", "@fastify/swagger-ui": "^4.1.0", - "@kynesyslabs/demosdk": "../sdks", + "@kynesyslabs/demosdk": "^2.3.22", "@modelcontextprotocol/sdk": "^1.13.3", "@octokit/core": "^6.1.5", "@types/express": "^4.17.21", diff --git a/src/features/web2/proxy/Proxy.ts b/src/features/web2/proxy/Proxy.ts index c23247878..8ed9ea160 100644 --- a/src/features/web2/proxy/Proxy.ts +++ b/src/features/web2/proxy/Proxy.ts @@ -3,6 +3,7 @@ import http from "http" import httpProxy from "http-proxy" import { URL } from "url" import net from "net" +import dns from "node:dns/promises" import { IWeb2Request, IWeb2Result, @@ -182,7 +183,69 @@ export class Proxy { private async createNewServer(targetUrl: string): Promise { return new Promise((resolve, reject) => { - const { targetProtocol } = this.parseUrl(targetUrl) + const { targetProtocol, targetHostname } = this.parseUrl(targetUrl) + + // SSRF hardening: resolve DNS and block private/link-local/loopback destinations + const isDisallowedAddress = (addr: string): boolean => { + const ipVersion = net.isIP(addr) + const lower = addr.toLowerCase() + if (ipVersion === 6) { + if (lower === "::1") return true + // fc00::/7 (ULA) and fe80::/10 (link-local) + if ( + lower.startsWith("fc") || + lower.startsWith("fd") || + lower.startsWith("fe80:") + ) + return true + // IPv4-mapped loopback (::ffff:127.x.x.x) + if (lower.startsWith("::ffff:127.")) return true + return false + } + if (ipVersion === 4) { + if (/^127(?:\.\d{1,3}){3}$/.test(lower)) return true + if (/^10\./.test(lower)) return true + const m = lower.match(/^172\.(\d{1,3})\./) + if (m) { + const o = Number(m[1]) + if (o >= 16 && o <= 31) return true + } + if (/^192\.168\./.test(lower)) return true + if (/^169\.254\./.test(lower)) return true + if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(lower)) + return true + if (/^0\./.test(lower)) return true + return false + } + return false + } + + const preflight = async () => { + try { + // If hostname is already an IP, just check it; otherwise resolve all + const ipVersion = net.isIP(targetHostname) + if (ipVersion) { + if (isDisallowedAddress(targetHostname)) { + throw new Error( + "Target resolves to a private/link-local/loopback address", + ) + } + } else { + const answers = await dns.lookup(targetHostname, { + all: true, + }) + if (answers.some(a => isDisallowedAddress(a.address))) { + throw new Error( + "Target resolves to a private/link-local/loopback address", + ) + } + } + } catch (e) { + reject(e) + return false + } + return true + } // Create the proxy server (defaults; per-request options are supplied in proxyServer.web) const proxyServer = httpProxy.createProxyServer({ @@ -254,13 +317,21 @@ export class Proxy { }) // Create the main HTTP server - this._server = http.createServer((req, res) => { + this._server = http.createServer(async (req, res) => { if (!this.isAuthorizedRequest(req)) { res.writeHead(403) res.end("Unauthorized") return } + // Ensure target is still safe at request time (DNS may have changed) + const ok = await preflight() + if (!ok) { + res.writeHead(400) + res.end("Invalid target host") + return + } + const { targetPathname, targetSearch, targetOrigin } = this.parseUrl(targetUrl) const outgoingPath = targetPathname + targetSearch diff --git a/src/features/web2/validator.ts b/src/features/web2/validator.ts index dd5d3d5ae..37d09194e 100644 --- a/src/features/web2/validator.ts +++ b/src/features/web2/validator.ts @@ -12,6 +12,8 @@ export type UrlValidationResult = * - Lowercases host, strips default ports, and removes fragments for canonicalization * - Redacts sensitive data in error messages (does not echo the full URL) */ +import net from "node:net" + export function validateAndNormalizeHttpUrl( input: string, ): UrlValidationResult { @@ -60,14 +62,41 @@ export function validateAndNormalizeHttpUrl( } } - // 5. Basic loopback check for IPv4 and IPv6 - const isIPv6Loopback = hostLower === "::1" || hostLower === "[::1]" + // 5. Block loopback and private/link-local/reserved ranges (IPv4, IPv6, and IPv4-mapped IPv6) + const ipVersion = net.isIP(hostLower) + const isIPv6Loopback = hostLower === "::1" + const isIPv4MappedLoopback = hostLower.startsWith("::ffff:127.") const isIPv4Loopback = /^127(?:\.\d{1,3}){3}$/.test(hostLower) - if (isIPv4Loopback || isIPv6Loopback) { + const isIPv4Private = + /^10\./.test(hostLower) || + (/^172\.(\d{1,3})\./.test(hostLower) && + (() => { + const m = hostLower.match(/^172\.(\d{1,3})\./) + if (!m) return false + const o = Number(m[1]) + return o >= 16 && o <= 31 + })()) || + /^192\.168\./.test(hostLower) || + /^169\.254\./.test(hostLower) || + /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(hostLower) || // 100.64.0.0/10 + /^0\./.test(hostLower) + const isIPv6ULAorLL = + ipVersion === 6 && + (hostLower.startsWith("fc") || + hostLower.startsWith("fd") || + hostLower.startsWith("fe80:")) + if ( + isIPv4Loopback || + isIPv6Loopback || + isIPv4MappedLoopback || + isIPv4Private || + isIPv6ULAorLL + ) { return { ok: false, status: 400, - message: "Loopback targets are not allowed", + message: + "Private, link-local, or loopback targets are not allowed", } } From a4b798c56c7fcedbbd67d3783bf9a65a0c299eae Mon Sep 17 00:00:00 2001 From: Massoud Valipoor Date: Sun, 7 Sep 2025 15:22:57 +0200 Subject: [PATCH 5/5] feat: improve URL validation and proxy security by blocking additional IPv6 multicast and unspecified addresses; enhance disallowed address checks for IPv4 --- src/features/web2/proxy/Proxy.ts | 55 +++++++++++++++++++------------- src/features/web2/validator.ts | 26 +++++++++++++-- 2 files changed, 57 insertions(+), 24 deletions(-) diff --git a/src/features/web2/proxy/Proxy.ts b/src/features/web2/proxy/Proxy.ts index 8ed9ea160..bd369fe1b 100644 --- a/src/features/web2/proxy/Proxy.ts +++ b/src/features/web2/proxy/Proxy.ts @@ -187,36 +187,47 @@ export class Proxy { // SSRF hardening: resolve DNS and block private/link-local/loopback destinations const isDisallowedAddress = (addr: string): boolean => { - const ipVersion = net.isIP(addr) const lower = addr.toLowerCase() - if (ipVersion === 6) { - if (lower === "::1") return true - // fc00::/7 (ULA) and fe80::/10 (link-local) - if ( - lower.startsWith("fc") || - lower.startsWith("fd") || - lower.startsWith("fe80:") - ) - return true - // IPv4-mapped loopback (::ffff:127.x.x.x) - if (lower.startsWith("::ffff:127.")) return true - return false - } - if (ipVersion === 4) { - if (/^127(?:\.\d{1,3}){3}$/.test(lower)) return true - if (/^10\./.test(lower)) return true - const m = lower.match(/^172\.(\d{1,3})\./) + const ipVersion = net.isIP(lower) + + // Helper for IPv4 space + const isDisallowedV4 = (v4: string): boolean => { + if (/^127(?:\.\d{1,3}){3}$/.test(v4)) return true // loopback + if (/^10\./.test(v4)) return true // private + const m = v4.match(/^172\.(\d{1,3})\./) if (m) { const o = Number(m[1]) if (o >= 16 && o <= 31) return true } - if (/^192\.168\./.test(lower)) return true - if (/^169\.254\./.test(lower)) return true - if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(lower)) + if (/^192\.168\./.test(v4)) return true // private + if (/^169\.254\./.test(v4)) return true // link-local + if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(v4)) + return true // CGNAT 100.64/10 + if (/^0\./.test(v4)) return true // this network + if (/^(?:22[4-9]|23\d)\./.test(v4)) return true // multicast 224/4 + if (/^(?:24\d|25[0-5])\./.test(v4)) return true // reserved 240/4 incl 255.255.255.255 + return false + } + + if (ipVersion === 6) { + if (lower === "::" || lower === "::1") return true // unspecified/loopback + if (lower.startsWith("ff")) return true // multicast ff00::/8 + // ULA fc00::/7 + if (lower.startsWith("fc") || lower.startsWith("fd")) return true - if (/^0\./.test(lower)) return true + // Link-local fe80::/10 → fe8x, fe9x, feax, febx + if (/^fe[89ab][0-9a-f]*:/i.test(lower)) return true + // IPv4-mapped IPv6 ::ffff:a.b.c.d → re-check mapped v4 + const v4map = lower.match( + /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/, + ) + if (v4map && isDisallowedV4(v4map[1])) return true return false } + + if (ipVersion === 4) { + return isDisallowedV4(lower) + } return false } diff --git a/src/features/web2/validator.ts b/src/features/web2/validator.ts index 37d09194e..df35bb674 100644 --- a/src/features/web2/validator.ts +++ b/src/features/web2/validator.ts @@ -64,7 +64,9 @@ export function validateAndNormalizeHttpUrl( // 5. Block loopback and private/link-local/reserved ranges (IPv4, IPv6, and IPv4-mapped IPv6) const ipVersion = net.isIP(hostLower) + const isIPv6Unspecified = hostLower === "::" const isIPv6Loopback = hostLower === "::1" + const isIPv6Multicast = hostLower.startsWith("ff") const isIPv4MappedLoopback = hostLower.startsWith("::ffff:127.") const isIPv4Loopback = /^127(?:\.\d{1,3}){3}$/.test(hostLower) const isIPv4Private = @@ -84,12 +86,32 @@ export function validateAndNormalizeHttpUrl( ipVersion === 6 && (hostLower.startsWith("fc") || hostLower.startsWith("fd") || - hostLower.startsWith("fe80:")) + /^fe[89ab][0-9a-f]*:/i.test(hostLower)) + // Also block IPv4-mapped private ranges (::ffff:10.x, ::ffff:172.16-31.x, ::ffff:192.168.x, ::ffff:169.254.x, ::ffff:100.64-127.x) + const mappedMatch = hostLower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/) + let isMappedPrivate = false + if (mappedMatch) { + const v4 = mappedMatch[1] + if (/^127(?:\.\d{1,3}){3}$/.test(v4)) isMappedPrivate = true + if (/^10\./.test(v4)) isMappedPrivate = true + const m172 = v4.match(/^172\.(\d{1,3})\./) + if (m172) { + const o = Number(m172[1]) + if (o >= 16 && o <= 31) isMappedPrivate = true + } + if (/^192\.168\./.test(v4)) isMappedPrivate = true + if (/^169\.254\./.test(v4)) isMappedPrivate = true + if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(v4)) + isMappedPrivate = true + if (/^0\./.test(v4)) isMappedPrivate = true + } if ( - isIPv4Loopback || + isIPv6Unspecified || isIPv6Loopback || + isIPv6Multicast || isIPv4MappedLoopback || isIPv4Private || + isMappedPrivate || isIPv6ULAorLL ) { return {