From 956e98b04191ea76b282cb9b045aebcb9e52f5f1 Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 6 Mar 2026 01:27:08 +0000 Subject: [PATCH] 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 --- packages/app/vite-rss.ts | 42 +++++++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/packages/app/vite-rss.ts b/packages/app/vite-rss.ts index 4936b977..1f6b2c94 100644 --- a/packages/app/vite-rss.ts +++ b/packages/app/vite-rss.ts @@ -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 { + 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 { 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)