security: SSRF protection on user-supplied RSS URLs

Validate every RSS URL submitted via /api/podcasts/subscribe before
fetching. Rejects non-http(s), localhost, private IPv4/IPv6 ranges,
link-local, and cloud metadata endpoints (169.254.169.254). Resolves
hostnames via DNS and re-checks every returned address.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Haiku 4.5 committed 2026-04-18 07:25:33 +02:00
1 parent 9bf95b4ac9
commit 5497c89a99
2 files changed
+88

No files matched your search

@@ -5,6 +5,7 @@ import { UserSubscription } from '../models/UserSubscription';
import { Episode } from '../models/Episode';
import { podcastIndexService } from '../services/podcastIndexService';
import { rssParserService } from '../services/rssParserService';
import { validateExternalUrl } from '../utils/urlValidator';
import { z } from 'zod';
const subscribeSchema = z.object({
@@ -52,6 +53,13 @@ export const subscribe = async (req: Request, res: Response) => {
const { rssUrl } = subscribeSchema.parse(req.body);
// SSRF protection — block internal/private hosts
try {
await validateExternalUrl(rssUrl);
} catch (err: any) {
return res.status(400).json({ message: `URL refusée : ${err.message}` });
}
// Check if podcast exists
let podcast = await Podcast.findOne({ rssUrl });
+80
View File
@@ -0,0 +1,80 @@
import { lookup } from 'dns/promises';
import { isIP } from 'net';
const BLOCKED_HOSTNAMES = new Set([
'localhost',
'127.0.0.1',
'0.0.0.0',
'::1',
'::',
'metadata.google.internal',
]);
const isPrivateIp = (ip: string): boolean => {
const v = isIP(ip);
if (v === 4) {
const parts = ip.split('.').map(Number);
if (parts[0] === 10) return true;
if (parts[0] === 127) return true;
if (parts[0] === 0) return true;
if (parts[0] === 169 && parts[1] === 254) return true; // link-local / cloud metadata
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
if (parts[0] === 192 && parts[1] === 168) return true;
return false;
}
if (v === 6) {
const lower = ip.toLowerCase();
if (lower === '::1' || lower === '::') return true;
if (lower.startsWith('fc') || lower.startsWith('fd')) return true; // unique local
if (lower.startsWith('fe80')) return true; // link-local
return false;
}
return false;
};
/**
* Validates an external URL is safe to fetch — blocks SSRF attempts
* to localhost, private networks, and cloud metadata endpoints.
*/
export const validateExternalUrl = async (url: string): Promise<URL> => {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error('Invalid URL');
}
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error('Only HTTP(S) URLs are allowed');
}
const hostname = parsed.hostname.toLowerCase();
if (BLOCKED_HOSTNAMES.has(hostname)) {
throw new Error('URL points to a blocked host');
}
if (isIP(hostname)) {
if (isPrivateIp(hostname)) {
throw new Error('URL points to a private network');
}
return parsed;
}
// Resolve hostname and check all A/AAAA records
try {
const records = await lookup(hostname, { all: true });
for (const r of records) {
if (isPrivateIp(r.address)) {
throw new Error('URL resolves to a private network');
}
}
} catch (err: any) {
if (err.message?.includes('private network') || err.message?.includes('blocked')) {
throw err;
}
throw new Error('Unable to resolve URL hostname');
}
return parsed;
};