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:
co-authored by
Claude Opus 4.6
parent
c21939b1f8
commit
956e98b041
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user