mirror of
https://github.com/R0m1k3/podcastic.git
synced 2026-10-20 05:28:03 +02:00
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:
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 });
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in new issue
Block a user