diff --git a/CHANGELOG.md b/CHANGELOG.md index e18e0e3..470f14e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ## [Unreleased] +### Added + +- Hardened incoming RSS/Atom sources with public-HTTPS validation, redirect checks, response-size limits, and safe XML declaration rejection. + ## [0.7.0] - 2026-07-19 ### Fixed diff --git a/lib/rss.ts b/lib/rss.ts index 0d13404..f1cd165 100644 --- a/lib/rss.ts +++ b/lib/rss.ts @@ -1,8 +1,35 @@ +import { resolve4, resolve6 } from "node:dns/promises"; +import { isIP } from "node:net"; + export type RssItem = { id: string; content: string; link: string; publishedAt: string | null }; -const decode = (value: string) => value.replace(//g, "$1").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&"); +const MAX_FEED_BYTES = 2 * 1024 * 1024; +const decode = (value: string) => value.replace(//g, "$1").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/&/g, "&"); const field = (xml: string, name: string) => decode(xml.match(new RegExp(`<${name}[^>]*>([\\s\\S]*?)`, "i"))?.[1]?.trim() || ""); -export async function fetchRss(url: string) { - const response = await fetch(url, { signal: AbortSignal.timeout(15_000), headers: { Accept: "application/rss+xml, application/xml, text/xml" } }); if (!response.ok) throw new Error(`RSS ${response.status}`); - const xml = await response.text(); const entries = xml.match(//gi) || []; - return entries.map((item) => { const link = field(item, "link"); const id = field(item, "guid") || link; return { id, link, content: field(item, "description") || field(item, "title"), publishedAt: field(item, "pubDate") ? new Date(field(item, "pubDate")).toISOString() : null }; }).filter((item) => item.id && item.content) as RssItem[]; +const atomLink = (xml: string) => decode(xml.match(/]*\bhref=["']([^"']+)["'][^>]*>/i)?.[1] || ""); +const date = (value: string) => { const parsed = new Date(value); return value && !Number.isNaN(parsed.getTime()) ? parsed.toISOString() : null; }; + +function privateIp(address: string) { + if (isIP(address) === 4) { const [a, b] = address.split(".").map(Number); return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168); } + const normalized = address.toLowerCase(); return normalized === "::1" || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe80:") || normalized.startsWith("::ffff:127.") || normalized.startsWith("::ffff:10.") || normalized.startsWith("::ffff:192.168."); +} +async function assertPublicHttps(raw: string) { + const url = new URL(raw); if (url.protocol !== "https:" || url.username || url.password) throw new Error("RSS feed must use a public HTTPS URL"); const host = url.hostname.toLowerCase(); if (host === "localhost" || host.endsWith(".local")) throw new Error("RSS feed host is not public"); + const direct = isIP(host); const addresses = direct ? [host] : [...await resolve4(host).catch(() => [] as string[]), ...await resolve6(host).catch(() => [] as string[])]; + if (!addresses.length || addresses.some(privateIp)) throw new Error("RSS feed host is not public"); return url; +} +async function responseText(response: Response) { + const headerSize = Number(response.headers.get("content-length") || 0); if (headerSize > MAX_FEED_BYTES) throw new Error("RSS feed is too large"); const reader = response.body?.getReader(); if (!reader) return ""; const chunks: Uint8Array[] = []; let total = 0; + while (true) { const { done, value } = await reader.read(); if (done) break; total += value.byteLength; if (total > MAX_FEED_BYTES) { await reader.cancel(); throw new Error("RSS feed is too large"); } chunks.push(value); } + return Buffer.concat(chunks.map((item) => Buffer.from(item))).toString("utf8"); +} + +export function parseFeed(xml: string): RssItem[] { + if (//gi) || []; const atom = xml.match(//gi) || []; const entries = rss.length ? rss.map((body) => ({ body, atom: false })) : atom.map((body) => ({ body, atom: true })); + return entries.map(({ body, atom }) => { const link = atom ? atomLink(body) : field(body, "link"); const id = field(body, atom ? "id" : "guid") || link; const content = field(body, atom ? "content" : "description") || field(body, atom ? "summary" : "title") || field(body, "title"); return { id, link, content, publishedAt: date(field(body, atom ? "published" : "pubDate") || field(body, atom ? "updated" : "")) }; }).filter((item) => item.id && item.content) as RssItem[]; +} +export async function fetchRss(raw: string) { + let url = await assertPublicHttps(raw); + for (let redirects = 0; redirects <= 3; redirects++) { const response = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(15_000), headers: { Accept: "application/rss+xml, application/atom+xml, application/xml, text/xml" } }); if ([301, 302, 303, 307, 308].includes(response.status)) { const location = response.headers.get("location"); if (!location || redirects === 3) throw new Error("RSS feed redirect is invalid"); url = await assertPublicHttps(new URL(location, url).toString()); continue; } if (!response.ok) throw new Error(`RSS ${response.status}`); return parseFeed(await responseText(response)); } + throw new Error("RSS feed redirect is invalid"); } diff --git a/tests/rss.test.ts b/tests/rss.test.ts new file mode 100644 index 0000000..723916c --- /dev/null +++ b/tests/rss.test.ts @@ -0,0 +1,11 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { parseFeed } from "../lib/rss"; + +test("parses RSS and Atom items without evaluating XML declarations", () => { + const rss = parseFeed("onehttps://example.test/one2026-07-19T00:00:00Z"); + assert.deepEqual(rss[0], { id: "one", link: "https://example.test/one", content: "Hello", publishedAt: "2026-07-19T00:00:00.000Z" }); + const atom = parseFeed("twoWorld2026-07-19T01:00:00Z"); + assert.deepEqual(atom[0], { id: "two", link: "https://example.test/two", content: "World", publishedAt: "2026-07-19T01:00:00.000Z" }); + assert.throws(() => parseFeed(""), /unsupported XML/); +});