feat: harden RSS source fetching
This commit is contained in:
@@ -4,6 +4,10 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.7.0] - 2026-07-19
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+32
-5
@@ -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 };
|
export type RssItem = { id: string; content: string; link: string; publishedAt: string | null };
|
||||||
const decode = (value: string) => value.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
const MAX_FEED_BYTES = 2 * 1024 * 1024;
|
||||||
|
const decode = (value: string) => value.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/&/g, "&");
|
||||||
const field = (xml: string, name: string) => decode(xml.match(new RegExp(`<${name}[^>]*>([\\s\\S]*?)</${name}>`, "i"))?.[1]?.trim() || "");
|
const field = (xml: string, name: string) => decode(xml.match(new RegExp(`<${name}[^>]*>([\\s\\S]*?)</${name}>`, "i"))?.[1]?.trim() || "");
|
||||||
export async function fetchRss(url: string) {
|
const atomLink = (xml: string) => decode(xml.match(/<link\b[^>]*\bhref=["']([^"']+)["'][^>]*>/i)?.[1] || "");
|
||||||
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 date = (value: string) => { const parsed = new Date(value); return value && !Number.isNaN(parsed.getTime()) ? parsed.toISOString() : null; };
|
||||||
const xml = await response.text(); const entries = xml.match(/<item\b[\s\S]*?<\/item>/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[];
|
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 (/<!DOCTYPE|<!ENTITY/i.test(xml)) throw new Error("RSS feed contains unsupported XML declarations");
|
||||||
|
const rss = xml.match(/<item\b[\s\S]*?<\/item>/gi) || []; const atom = xml.match(/<entry\b[\s\S]*?<\/entry>/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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("<rss><channel><item><guid>one</guid><link>https://example.test/one</link><description><![CDATA[Hello]]></description><pubDate>2026-07-19T00:00:00Z</pubDate></item></channel></rss>");
|
||||||
|
assert.deepEqual(rss[0], { id: "one", link: "https://example.test/one", content: "Hello", publishedAt: "2026-07-19T00:00:00.000Z" });
|
||||||
|
const atom = parseFeed("<feed><entry><id>two</id><link href=\"https://example.test/two\"/><summary>World</summary><updated>2026-07-19T01:00:00Z</updated></entry></feed>");
|
||||||
|
assert.deepEqual(atom[0], { id: "two", link: "https://example.test/two", content: "World", publishedAt: "2026-07-19T01:00:00.000Z" });
|
||||||
|
assert.throws(() => parseFeed("<!DOCTYPE feed><feed />"), /unsupported XML/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user