fix(app): add SSRF protection to RSS fetcher

Add post-DNS SSRF validation using dns.lookup() to verify resolved IPs
are not in private ranges. Block non-http(s) schemes (file://, ftp://)
in discoverFeedUrl(). Extract isPrivateIp() helper for reuse.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-06 01:27:08 +00:00
co-authored by Claude Opus 4.6
parent c21939b1f8
commit 956e98b041
+33 -9
View File
@@ -1,6 +1,7 @@
import type { Plugin } from 'vite'
import type { Connect } from 'vite'
import Parser from 'rss-parser'
import { lookup } from 'dns/promises'
import { validateDevAuth, setCorsHeaders } from './server/dev-auth'
export interface RssArticle {
@@ -57,30 +58,47 @@ async function tryParseFeed(parser: Parser, feedUrl: string): Promise<RssArticle
}
}
function isPrivateIp(ip: string): boolean {
if (ip === '127.0.0.1' || ip === '::1' || ip === '0.0.0.0') return true
if (/^10\./.test(ip)) return true
if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true
if (/^192\.168\./.test(ip)) return true
if (/^169\.254\./.test(ip)) return true
if (/^fc|^fd/i.test(ip)) return true // IPv6 unique local
if (/^fe80/i.test(ip)) return true // IPv6 link-local
return false
}
function isPrivateUrl(urlStr: string): boolean {
try {
const u = new URL(urlStr)
// Block non-http(s) schemes
if (u.protocol !== 'https:' && u.protocol !== 'http:') return true
const hostname = u.hostname.toLowerCase()
// Block localhost
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]') return true
// Block private IPv4 ranges
if (/^10\./.test(hostname)) return true
if (/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)) return true
if (/^192\.168\./.test(hostname)) return true
// Block link-local
if (/^169\.254\./.test(hostname)) return true
// Block 0.0.0.0
if (hostname === '0.0.0.0') return true
if (isPrivateIp(hostname)) return true
return false
} catch {
return true
}
}
/** Post-DNS SSRF protection: resolve hostname and verify IP is not private */
async function validateResolvedIp(hostname: string): Promise<boolean> {
try {
const result = await lookup(hostname)
return !isPrivateIp(result.address)
} catch {
return false // DNS failure = block
}
}
function discoverFeedUrl(siteUrl: string): string[] {
try {
if (isPrivateUrl(siteUrl)) return []
const u = new URL(siteUrl)
// Only allow http(s) schemes
if (u.protocol !== 'https:' && u.protocol !== 'http:') return []
const base = `${u.protocol}//${u.host}`
return RSS_PATHS.map((path) => base + path)
} catch {
@@ -98,6 +116,12 @@ async function fetchRssFromUrls(urls: string[]): Promise<RssArticle[]> {
const articles: RssArticle[] = []
for (const url of urls.slice(0, 5)) {
// Post-DNS SSRF: verify resolved IP is not private
try {
const hostname = new URL(url).hostname
if (!(await validateResolvedIp(hostname))) continue
} catch { continue }
const candidates = discoverFeedUrl(url)
for (const feedUrl of candidates) {
const items = await tryParseFeed(parser, feedUrl)