pingql/apps/api/src/utils/ssrf.ts

99 lines
2.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}