refactor(server): extract SSRF validation into shared ssrf.ts utility

This commit is contained in:
Jannis Braun
2026-03-25 01:48:18 +01:00
parent d9094fabe1
commit 2f883c6f63
2 changed files with 47 additions and 36 deletions
+2 -36
View File
@@ -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<UrlMetadata | null> {
// 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 {
+45
View File
@@ -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<void> {
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');
}
}