99 lines
2.8 KiB
TypeScript
99 lines
2.8 KiB
TypeScript
import { createHash } from "crypto";
|
||
import dns from "dns/promises";
|
||
|
||
const BLOCKED_TLDS = [".local", ".internal", ".corp", ".lan"];
|
||
const BLOCKED_HOSTNAMES = ["localhost", "localhost."];
|
||
|
||
/**
|
||
* Checks whether an IP address is in a private/reserved range.
|
||
*/
|
||
function isPrivateIP(ip: string): boolean {
|
||
// IPv4
|
||
if (ip === "0.0.0.0") return true;
|
||
if (ip.startsWith("127.")) return true; // 127.0.0.0/8
|
||
if (ip.startsWith("10.")) return true; // 10.0.0.0/8
|
||
if (ip.startsWith("192.168.")) return true; // 192.168.0.0/16
|
||
if (ip.startsWith("169.254.")) return true; // 169.254.0.0/16 (link-local + cloud metadata)
|
||
|
||
// 172.16.0.0/12: 172.16.x.x – 172.31.x.x
|
||
if (ip.startsWith("172.")) {
|
||
const second = parseInt(ip.split(".")[1] ?? "", 10);
|
||
if (second >= 16 && second <= 31) return true;
|
||
}
|
||
|
||
// IPv6 — normalize: strip zone ID (%eth0) and lowercase
|
||
const ip6 = ip.replace(/%.*$/, "").toLowerCase();
|
||
if (ip6 === "::1" || ip6 === "::") return true;
|
||
if (ip6.startsWith("fe80")) return true; // fe80::/10 link-local
|
||
if (ip6.startsWith("fc") || ip6.startsWith("fd")) return true; // fc00::/7 unique-local (ULA)
|
||
if (ip6.startsWith("fd00:ec2::")) return true; // AWS EC2 metadata IPv6
|
||
if (ip6 === "::ffff:127.0.0.1") return true;
|
||
if (ip6.startsWith("::ffff:")) {
|
||
// IPv4-mapped IPv6 — extract the IPv4 part and re-check
|
||
const v4 = ip6.slice(7);
|
||
return isPrivateIP(v4);
|
||
}
|
||
// IPv4-compatible IPv6 (deprecated but still reachable)
|
||
if (ip6.startsWith("::") && ip6.includes(".")) {
|
||
const v4 = ip6.slice(2);
|
||
return isPrivateIP(v4);
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Validates a monitor URL is safe to fetch (not targeting internal resources).
|
||
* Returns null if safe, or an error string if blocked.
|
||
*/
|
||
export async function validateMonitorUrl(url: string): Promise<string | null> {
|
||
let parsed: URL;
|
||
try {
|
||
parsed = new URL(url);
|
||
} catch {
|
||
return "Invalid URL";
|
||
}
|
||
|
||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||
return `Blocked scheme: ${parsed.protocol} — only http: and https: are allowed`;
|
||
}
|
||
|
||
const hostname = parsed.hostname.toLowerCase();
|
||
|
||
if (BLOCKED_HOSTNAMES.includes(hostname)) {
|
||
return "Blocked hostname: localhost is not allowed";
|
||
}
|
||
|
||
for (const tld of BLOCKED_TLDS) {
|
||
if (hostname.endsWith(tld)) {
|
||
return `Blocked TLD: ${tld} is not allowed`;
|
||
}
|
||
}
|
||
|
||
try {
|
||
const ips: string[] = [];
|
||
try {
|
||
const v4 = await dns.resolve4(hostname);
|
||
ips.push(...v4);
|
||
} catch {}
|
||
try {
|
||
const v6 = await dns.resolve6(hostname);
|
||
ips.push(...v6);
|
||
} catch {}
|
||
|
||
if (ips.length === 0) {
|
||
return "Could not resolve hostname";
|
||
}
|
||
|
||
for (const ip of ips) {
|
||
if (isPrivateIP(ip)) {
|
||
return `Blocked: ${hostname} resolves to private/reserved IP ${ip}`;
|
||
}
|
||
}
|
||
} catch {
|
||
return "DNS resolution failed";
|
||
}
|
||
|
||
return null;
|
||
}
|