Files
archy/packages/app/vite-rss.ts
T
DorianandClaude Opus 4.6 956e98b041 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>
2026-03-06 01:27:08 +00:00

189 lines
6.5 KiB
TypeScript

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 {
title: string
url: string
content?: string
imgSrc?: string
}
const RSS_PATHS = ['/feed', '/rss', '/atom.xml', '/feed.xml', '/?feed=rss', '/rss.xml', '/.rss', '/feeds/posts/default']
function extractImgFromItem(item: Record<string, unknown>): string | undefined {
const enc = item.enclosure as { url?: string; type?: string } | undefined
if (enc?.url && /^https?:\/\//i.test(enc.url)) {
const t = (enc.type ?? '').toLowerCase()
if (t.startsWith('image/') || t === '') return enc.url
}
const thumb = item['media:thumbnail'] as { $?: { url?: string }; url?: string } | undefined
if (thumb?.$?.url) return thumb.$.url
if (typeof thumb?.url === 'string') return thumb.url
const arr = thumb as unknown[]
if (Array.isArray(arr) && arr[0]?.$?.url) return (arr[0] as { $: { url: string } }).$.url
const media = item['media:content'] as { $?: { url?: string }; url?: string } | undefined
if (media?.$?.url) return media.$.url
if (typeof media?.url === 'string') return media.url
const group = item['media:group'] as { 'media:content'?: Array<{ $?: { url?: string } }> } | undefined
if (group?.['media:content']?.[0]?.$?.url) return group['media:content'][0].$.url
const itunes = item['itunes:image'] as { href?: string } | undefined
if (itunes?.href) return itunes.href
const content = item.content ?? item.summary
const html = typeof content === 'string' ? content : ''
const imgMatch = html.match(/<img[^>]+src=["']([^"']+)["']/i)
if (imgMatch?.[1]) return imgMatch[1]
return undefined
}
async function tryParseFeed(parser: Parser, feedUrl: string): Promise<RssArticle[] | null> {
try {
const feed = await parser.parseURL(feedUrl)
if (!feed?.items?.length) return null
return feed.items.slice(0, 10).map((item) => {
const rawContent = item.contentSnippet ?? item.content ?? item.summary
const contentStr = typeof rawContent === 'string' ? rawContent : ''
const imgSrc = extractImgFromItem(item as Record<string, unknown>)
return {
title: (item.title ?? '').trim() || 'Untitled',
url: (item.link ?? item.guid ?? '').trim() || feedUrl,
content: contentStr.trim().slice(0, 15000),
imgSrc: imgSrc && /^https?:\/\//i.test(imgSrc) ? imgSrc : undefined,
}
})
} catch {
return null
}
}
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()
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]') 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 {
return []
}
}
async function fetchRssFromUrls(urls: string[]): Promise<RssArticle[]> {
const parser = new Parser({
timeout: 5000,
headers: { 'User-Agent': 'AIUI/1.0 (RSS reader)' },
})
const seen = new Set<string>()
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)
if (!items?.length) continue
for (const a of items) {
const k = a.url.toLowerCase()
if (seen.has(k)) continue
seen.add(k)
articles.push(a)
}
break // found a feed for this site, move to next URL
}
}
return articles.slice(0, 15)
}
function createRssMiddleware() {
return async (req: Connect.IncomingMessage, res: any, next: () => void) => {
if (req.method !== 'GET') return next()
if (!validateDevAuth(req, res)) return
const requestUrl = new URL(req.url ?? '', `http://${req.headers?.host ?? 'localhost'}`)
if (!requestUrl.pathname.startsWith('/api/rss-articles')) return next()
const urls = requestUrl.searchParams.getAll('url').map((u) => u.trim()).filter(Boolean)
if (urls.length === 0) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Missing urls (array or comma-separated)' }))
return
}
const safe = urls.filter((u) => /^https?:\/\//i.test(u.trim()) && !isPrivateUrl(u.trim())).slice(0, 8)
if (safe.length === 0) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'No valid https URLs' }))
return
}
try {
const articles = await fetchRssFromUrls(safe)
res.setHeader('Content-Type', 'application/json')
setCorsHeaders(res)
res.setHeader('Cache-Control', 'public, max-age=300')
res.end(JSON.stringify({ articles }))
} catch (err) {
console.error('[rss]', err)
res.writeHead(502, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: String(err) }))
}
}
}
export function rssPlugin(): Plugin {
return {
name: 'aiui-rss',
configureServer(server) {
server.middlewares.use(createRssMiddleware())
},
configurePreviewServer(server) {
server.middlewares.use(createRssMiddleware())
},
}
}