src / engine / normalization / UrlValidator.ts
src / engine / normalization / UrlValidator.ts
import * as net from "net";
/**
* Pure helper utility for URL syntax validation and scheme verification.
*/
export class UrlValidator {
private static readonly FAST_PRIVATE_HOST_REGEX =
/^(localhost|127\.\d+\.\d+\.\d+|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|169\.254\.\d+\.\d+|0\.0\.0\.0|::1|\[::1\]|0x7f[0-9a-f]+|0177\.\d+\.\d+\.\d+)$/i;
private static readonly PRIVATE_SUFFIX_REGEX =
/\.(local|internal|lan|home|arpa)$/i;
/**
* Validates and returns a clean absolute HTTP or HTTPS URL string, or undefined if invalid.
*/
public static validateUrl(candidateUrl?: string): string | undefined {
if (!candidateUrl || typeof candidateUrl !== "string" || !candidateUrl.trim()) {
return undefined;
}
const trimmed = candidateUrl.trim();
try {
const parsed = new URL(trimmed);
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
return parsed.href;
}
} catch {
// Invalid URL syntax
}
return undefined;
}
/**
* Synchronous check for known private hostname patterns.
*/
public static isPrivateHostname(hostname: string): boolean {
if (!hostname) return true;
const cleanHost = hostname.replace(/^\[|\]$/g, "").split("%")[0].toLowerCase();
if (UrlValidator.FAST_PRIVATE_HOST_REGEX.test(cleanHost)) return true;
if (UrlValidator.PRIVATE_SUFFIX_REGEX.test(cleanHost)) return true;
return false;
}
/**
* Synchronous check for IPv4 and IPv6 private/loopback/multicast/reserved addresses.
* Includes CGNAT (100.64.0.0/10), IPv4 Multicast (224.0.0.0/4), IPv4 Reserved (240.0.0.0/4), and IPv6 Multicast (ff00::/8).
*/
public static isPrivateIP(ipAddress: string): boolean {
if (!ipAddress) return true;
// Strip brackets and zone identifiers (e.g. "fe80::1%eth0" -> "fe80::1")
let ip = ipAddress.replace(/^\[|\]$/g, "").split("%")[0].trim().toLowerCase();
// Handle IPv4-mapped IPv6 addresses (e.g. "::ffff:127.0.0.1")
if (ip.startsWith("::ffff:") || ip.includes(":ffff:")) {
const parts = ip.split(":");
const lastPart = parts[parts.length - 1];
if (net.isIPv4(lastPart)) {
ip = lastPart;
}
}
// IPv4 Checks
if (net.isIPv4(ip)) {
const octets = ip.split(".").map(Number);
const o1 = octets[0];
const o2 = octets[1];
// Loopback (127.0.0.0/8), Private (10.0.0.0/8), Link-Local (169.254.0.0/16), Broadcast (0.0.0.0/8)
if (o1 === 127 || o1 === 10 || (o1 === 169 && o2 === 254) || o1 === 0) return true;
// Private LAN (192.168.0.0/16)
if (o1 === 192 && o2 === 168) return true;
// Private LAN (172.16.0.0/12)
if (o1 === 172 && o2 >= 16 && o2 <= 31) return true;
// CGNAT (100.64.0.0/10)
if (o1 === 100 && o2 >= 64 && o2 <= 127) return true;
// Multicast (224.0.0.0/4) & Reserved/Experimental (240.0.0.0/4)
if (o1 >= 224) return true;
return false;
}
// IPv6 Checks
if (net.isIPv6(ip)) {
if (ip === "::1" || ip === "::" || ip === "0:0:0:0:0:0:0:1" || ip === "0:0:0:0:0:0:0:0") return true;
if (ip.startsWith("fe80:") || ip.startsWith("fc") || ip.startsWith("fd") || ip.startsWith("ff")) return true;
if (/^0+:0+:0+:0+:0+:0+:0+:0*1$/.test(ip)) return true;
return false;
}
return true; // Reject unrecognized IP representations
}
}
import * as net from "net";
/**
* Pure helper utility for URL syntax validation and scheme verification.
*/
export class UrlValidator {
private static readonly FAST_PRIVATE_HOST_REGEX =
/^(localhost|127\.\d+\.\d+\.\d+|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|169\.254\.\d+\.\d+|0\.0\.0\.0|::1|\[::1\]|0x7f[0-9a-f]+|0177\.\d+\.\d+\.\d+)$/i;
private static readonly PRIVATE_SUFFIX_REGEX =
/\.(local|internal|lan|home|arpa)$/i;
/**
* Validates and returns a clean absolute HTTP or HTTPS URL string, or undefined if invalid.
*/
public static validateUrl(candidateUrl?: string): string | undefined {
if (!candidateUrl || typeof candidateUrl !== "string" || !candidateUrl.trim()) {
return undefined;
}
const trimmed = candidateUrl.trim();
try {
const parsed = new URL(trimmed);
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
return parsed.href;
}
} catch {
// Invalid URL syntax
}
return undefined;
}
/**
* Synchronous check for known private hostname patterns.
*/
public static isPrivateHostname(hostname: string): boolean {
if (!hostname) return true;
const cleanHost = hostname.replace(/^\[|\]$/g, "").split("%")[0].toLowerCase();
if (UrlValidator.FAST_PRIVATE_HOST_REGEX.test(cleanHost)) return true;
if (UrlValidator.PRIVATE_SUFFIX_REGEX.test(cleanHost)) return true;
return false;
}
/**
* Synchronous check for IPv4 and IPv6 private/loopback/multicast/reserved addresses.
* Includes CGNAT (100.64.0.0/10), IPv4 Multicast (224.0.0.0/4), IPv4 Reserved (240.0.0.0/4), and IPv6 Multicast (ff00::/8).
*/
public static isPrivateIP(ipAddress: string): boolean {
if (!ipAddress) return true;
// Strip brackets and zone identifiers (e.g. "fe80::1%eth0" -> "fe80::1")
let ip = ipAddress.replace(/^\[|\]$/g, "").split("%")[0].trim().toLowerCase();
// Handle IPv4-mapped IPv6 addresses (e.g. "::ffff:127.0.0.1")
if (ip.startsWith("::ffff:") || ip.includes(":ffff:")) {
const parts = ip.split(":");
const lastPart = parts[parts.length - 1];
if (net.isIPv4(lastPart)) {
ip = lastPart;
}
}
// IPv4 Checks
if (net.isIPv4(ip)) {
const octets = ip.split(".").map(Number);
const o1 = octets[0];
const o2 = octets[1];
// Loopback (127.0.0.0/8), Private (10.0.0.0/8), Link-Local (169.254.0.0/16), Broadcast (0.0.0.0/8)
if (o1 === 127 || o1 === 10 || (o1 === 169 && o2 === 254) || o1 === 0) return true;
// Private LAN (192.168.0.0/16)
if (o1 === 192 && o2 === 168) return true;
// Private LAN (172.16.0.0/12)
if (o1 === 172 && o2 >= 16 && o2 <= 31) return true;
// CGNAT (100.64.0.0/10)
if (o1 === 100 && o2 >= 64 && o2 <= 127) return true;
// Multicast (224.0.0.0/4) & Reserved/Experimental (240.0.0.0/4)
if (o1 >= 224) return true;
return false;
}
// IPv6 Checks
if (net.isIPv6(ip)) {
if (ip === "::1" || ip === "::" || ip === "0:0:0:0:0:0:0:1" || ip === "0:0:0:0:0:0:0:0") return true;
if (ip.startsWith("fe80:") || ip.startsWith("fc") || ip.startsWith("fd") || ip.startsWith("ff")) return true;
if (/^0+:0+:0+:0+:0+:0+:0+:0*1$/.test(ip)) return true;
return false;
}
return true; // Reject unrecognized IP representations
}
}