From 2f883c6f63b0e7b4de21abe798d516b0e20e05e5 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 25 Mar 2026 01:48:18 +0100 Subject: [PATCH] refactor(server): extract SSRF validation into shared ssrf.ts utility --- packages/server/src/utils/metadataFetcher.ts | 38 +---------------- packages/server/src/utils/ssrf.ts | 45 ++++++++++++++++++++ 2 files changed, 47 insertions(+), 36 deletions(-) create mode 100644 packages/server/src/utils/ssrf.ts diff --git a/packages/server/src/utils/metadataFetcher.ts b/packages/server/src/utils/metadataFetcher.ts index 9eb1d5ed..e5562921 100644 --- a/packages/server/src/utils/metadataFetcher.ts +++ b/packages/server/src/utils/metadataFetcher.ts @@ -1,20 +1,5 @@ -import dns from 'dns'; import * as cheerio from 'cheerio'; - -export function isPrivateIp(ip: string): boolean { - // IPv4 - if (ip.startsWith('127.') || ip.startsWith('0.') || ip === '0.0.0.0') return true; - if (ip.startsWith('10.')) return true; - if (ip.startsWith('192.168.')) return true; - if (ip.startsWith('169.254.')) return true; - if (ip.startsWith('172.')) { - const second = parseInt(ip.split('.')[1] ?? '', 10); - if (second >= 16 && second <= 31) return true; - } - // IPv6 - if (ip === '::1' || ip.startsWith('fc') || ip.startsWith('fd') || ip.startsWith('fe80')) return true; - return false; -} +import { validateExternalUrl } from './ssrf.js'; export interface UrlMetadata { title: string | null; @@ -27,31 +12,12 @@ export interface UrlMetadata { } export async function fetchUrlMetadata(url: string): Promise { - // Validate URL scheme - let parsed: URL; try { - parsed = new URL(url); + await validateExternalUrl(url); } catch { return null; } - if (!['http:', 'https:'].includes(parsed.protocol)) { - return null; - } - - // Resolve hostname and block private/internal IPs - let address: string; - try { - const result = await dns.promises.lookup(parsed.hostname); - address = result.address; - } catch { - return null; - } - - if (isPrivateIp(address)) { - return null; - } - const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); try { diff --git a/packages/server/src/utils/ssrf.ts b/packages/server/src/utils/ssrf.ts new file mode 100644 index 00000000..bbd097d7 --- /dev/null +++ b/packages/server/src/utils/ssrf.ts @@ -0,0 +1,45 @@ +import dns from 'dns'; + +export function isPrivateIp(ip: string): boolean { + // IPv4 + if (ip.startsWith('127.') || ip.startsWith('0.') || ip === '0.0.0.0') return true; + if (ip.startsWith('10.')) return true; + if (ip.startsWith('192.168.')) return true; + if (ip.startsWith('169.254.')) return true; + if (ip.startsWith('172.')) { + const second = parseInt(ip.split('.')[1] ?? '', 10); + if (second >= 16 && second <= 31) return true; + } + // IPv6 + if (ip === '::1' || ip.startsWith('fc') || ip.startsWith('fd') || ip.startsWith('fe80')) return true; + return false; +} + +/** + * Validate that a URL is safe for outbound fetch (not private/internal). + * Throws on invalid scheme, DNS failure, or private IP resolution. + */ +export async function validateExternalUrl(url: string): Promise { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new Error('Invalid URL'); + } + + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw new Error('Invalid URL scheme'); + } + + let address: string; + try { + const result = await dns.promises.lookup(parsed.hostname); + address = result.address; + } catch { + throw new Error('DNS lookup failed'); + } + + if (isPrivateIp(address)) { + throw new Error('Private IP not allowed'); + } +}