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 { Plugin } from 'vite'
|
||||||
import type { Connect } from 'vite'
|
import type { Connect } from 'vite'
|
||||||
import Parser from 'rss-parser'
|
import Parser from 'rss-parser'
|
||||||
|
import { lookup } from 'dns/promises'
|
||||||
import { validateDevAuth, setCorsHeaders } from './server/dev-auth'
|
import { validateDevAuth, setCorsHeaders } from './server/dev-auth'
|
||||||
|
|
||||||
export interface RssArticle {
|
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 {
|
function isPrivateUrl(urlStr: string): boolean {
|
||||||
try {
|
try {
|
||||||
const u = new URL(urlStr)
|
const u = new URL(urlStr)
|
||||||
|
// Block non-http(s) schemes
|
||||||
|
if (u.protocol !== 'https:' && u.protocol !== 'http:') return true
|
||||||
const hostname = u.hostname.toLowerCase()
|
const hostname = u.hostname.toLowerCase()
|
||||||
// Block localhost
|
|
||||||
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]') return true
|
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]') return true
|
||||||
// Block private IPv4 ranges
|
if (isPrivateIp(hostname)) return true
|
||||||
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
|
|
||||||
return false
|
return false
|
||||||
} catch {
|
} catch {
|
||||||
return true
|
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[] {
|
function discoverFeedUrl(siteUrl: string): string[] {
|
||||||
try {
|
try {
|
||||||
if (isPrivateUrl(siteUrl)) return []
|
if (isPrivateUrl(siteUrl)) return []
|
||||||
const u = new URL(siteUrl)
|
const u = new URL(siteUrl)
|
||||||
|
// Only allow http(s) schemes
|
||||||
|
if (u.protocol !== 'https:' && u.protocol !== 'http:') return []
|
||||||
const base = `${u.protocol}//${u.host}`
|
const base = `${u.protocol}//${u.host}`
|
||||||
return RSS_PATHS.map((path) => base + path)
|
return RSS_PATHS.map((path) => base + path)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -98,6 +116,12 @@ async function fetchRssFromUrls(urls: string[]): Promise<RssArticle[]> {
|
|||||||
const articles: RssArticle[] = []
|
const articles: RssArticle[] = []
|
||||||
|
|
||||||
for (const url of urls.slice(0, 5)) {
|
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)
|
const candidates = discoverFeedUrl(url)
|
||||||
for (const feedUrl of candidates) {
|
for (const feedUrl of candidates) {
|
||||||
const items = await tryParseFeed(parser, feedUrl)
|
const items = await tryParseFeed(parser, feedUrl)
|
||||||
|
|||||||
Reference in New Issue
Block a user