57 lines
1.7 KiB
TypeScript
57 lines
1.7 KiB
TypeScript
import dns from "node:dns/promises";
|
|
|
|
function isPrivateAddress(ip: string): boolean {
|
|
const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
if (v4) {
|
|
const [, a, b, c] = v4.map(Number) as [number, number, number, number, number];
|
|
if (a === 127) return true;
|
|
if (a === 10) return true;
|
|
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
if (a === 192 && b === 168) return true;
|
|
if (a === 169 && b === 254) return true;
|
|
if (a >= 224) return true;
|
|
return false;
|
|
}
|
|
|
|
const lower = ip.toLowerCase();
|
|
if (lower === "::1" || lower === "::") return true;
|
|
if (lower.startsWith("fe80:")) return true;
|
|
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
|
const v4mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
if (v4mapped) return isPrivateAddress(v4mapped[1]!);
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Returns an error string if the URL is disallowed (SSRF), or null if safe.
|
|
* Blocks non-http(s) schemes, RFC-1918, loopback, link-local, cloud metadata.
|
|
*/
|
|
export async function validateWebhookUrl(rawUrl: string): Promise<string | null> {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(rawUrl);
|
|
} catch {
|
|
return "Invalid URL";
|
|
}
|
|
|
|
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
return "Webhook URL must use http or https";
|
|
}
|
|
|
|
let addresses: string[];
|
|
try {
|
|
const results = await dns.lookup(url.hostname, { all: true, family: 0 });
|
|
addresses = results.map((r) => r.address);
|
|
} catch {
|
|
return "Unable to resolve webhook hostname";
|
|
}
|
|
|
|
for (const addr of addresses) {
|
|
if (isPrivateAddress(addr)) {
|
|
return "Webhook URL must not point to a private or reserved address";
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|