From e1d1a3e53d65794ec2f23099bafe77fdfdd11648 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Wed, 15 Jul 2026 14:55:27 +0800 Subject: [PATCH] Merge commit from fork --- src/backend/utils/notification-sender.ts | 3 +- src/backend/utils/safe-outbound-fetch.ts | 94 ++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 src/backend/utils/safe-outbound-fetch.ts diff --git a/src/backend/utils/notification-sender.ts b/src/backend/utils/notification-sender.ts index eb83cf70..768bef2d 100644 --- a/src/backend/utils/notification-sender.ts +++ b/src/backend/utils/notification-sender.ts @@ -1,4 +1,5 @@ import { statsLogger } from "./logger.js"; +import { safeOutboundFetch } from "./safe-outbound-fetch.js"; export interface AlertPayload { hostName: string; @@ -36,7 +37,7 @@ async function fetchWithRetry( options: RequestInit, ): Promise { const attempt = async () => { - const res = await fetch(url, options); + const res = await safeOutboundFetch(url, options); if (!res.ok) { throw new Error(`HTTP ${res.status}: ${res.statusText}`); } diff --git a/src/backend/utils/safe-outbound-fetch.ts b/src/backend/utils/safe-outbound-fetch.ts new file mode 100644 index 00000000..1448be0e --- /dev/null +++ b/src/backend/utils/safe-outbound-fetch.ts @@ -0,0 +1,94 @@ +import { lookup } from "dns"; +import { BlockList, isIP } from "net"; +import { Agent } from "undici"; + +const blockedAddresses = new BlockList(); + +for (const [network, prefix] of [ + ["0.0.0.0", 8], + ["10.0.0.0", 8], + ["100.64.0.0", 10], + ["127.0.0.0", 8], + ["169.254.0.0", 16], + ["172.16.0.0", 12], + ["192.168.0.0", 16], + ["198.18.0.0", 15], + ["224.0.0.0", 4], + ["240.0.0.0", 4], +] as const) { + blockedAddresses.addSubnet(network, prefix, "ipv4"); +} + +for (const [network, prefix] of [ + ["::", 128], + ["::1", 128], + ["::ffff:0:0", 96], + ["fc00::", 7], + ["fe80::", 10], + ["ff00::", 8], +] as const) { + blockedAddresses.addSubnet(network, prefix, "ipv6"); +} + +function isBlockedAddress(address: string): boolean { + const family = isIP(address); + return ( + family === 0 || + blockedAddresses.check(address, family === 4 ? "ipv4" : "ipv6") + ); +} + +export async function safeOutboundFetch( + rawUrl: string, + options: RequestInit, +): Promise { + const url = new URL(rawUrl); + if ( + !["http:", "https:"].includes(url.protocol) || + url.username || + url.password + ) { + throw new Error("Invalid outbound URL"); + } + + const hostname = url.hostname.replace(/^\[|\]$/g, ""); + if (isIP(hostname) && isBlockedAddress(hostname)) { + throw new Error("Private destinations are not allowed"); + } + + const dispatcher = new Agent({ + connect: { + lookup(host, lookupOptions, callback) { + lookup( + host, + { ...lookupOptions, all: true, verbatim: true }, + (error, addresses) => { + if (error) return callback(error, "", 0); + if ( + !addresses.length || + addresses.some(({ address }) => isBlockedAddress(address)) + ) { + return callback( + new Error("Private destinations are not allowed"), + "", + 0, + ); + } + const selected = addresses[0]; + callback(null, selected.address, selected.family); + }, + ); + }, + }, + }); + + try { + return await fetch(url, { + ...options, + redirect: "error", + dispatcher, + } as RequestInit & { dispatcher: Agent }); + } finally { + await dispatcher.close(); + } +}