diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/lib/Utility/sanitizeHtml.test.ts | 57 | ||||
| -rw-r--r-- | src/lib/Utility/sanitizeHtml.ts | 32 | ||||
| -rw-r--r-- | src/routes/updates/+page.svelte | 23 |
3 files changed, 110 insertions, 2 deletions
diff --git a/src/lib/Utility/sanitizeHtml.test.ts b/src/lib/Utility/sanitizeHtml.test.ts new file mode 100644 index 00000000..1094635e --- /dev/null +++ b/src/lib/Utility/sanitizeHtml.test.ts @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +import { describe, expect, it } from "vitest"; +import { sanitizeFeedHtml } from "./sanitizeHtml"; + +describe("sanitizeFeedHtml", () => { + // Behaviour gate: the formatting real feeds use must survive untouched. + it("preserves entities, inline formatting and safe links", () => { + expect(sanitizeFeedHtml("Fruits & Vegetables")).toBe( + "Fruits & Vegetables", + ); + expect(sanitizeFeedHtml("<i>italic</i> and <b>bold</b>")).toBe( + "<i>italic</i> and <b>bold</b>", + ); + expect(sanitizeFeedHtml("Vol. 1 <em>Ch.</em> 5")).toBe( + "Vol. 1 <em>Ch.</em> 5", + ); + expect( + sanitizeFeedHtml('<a href="https://example.com/x">link</a>'), + ).toContain('href="https://example.com/x"'); + expect(sanitizeFeedHtml("line<br>break")).toContain("<br"); + }); + + it("returns empty string for nullish input", () => { + expect(sanitizeFeedHtml(undefined)).toBe(""); + expect(sanitizeFeedHtml(null)).toBe(""); + expect(sanitizeFeedHtml("")).toBe(""); + }); + + // The fix: scripts, handlers, dangerous tags and URLs must be removed. + it("strips scripts, event handlers and dangerous tags/urls", () => { + const script = sanitizeFeedHtml("<script>alert(1)</script>safe"); + expect(script).not.toContain("script"); + expect(script).toContain("safe"); + + const onerror = sanitizeFeedHtml("before<img src=x onerror=alert(1)>after"); + expect(onerror).not.toContain("onerror"); + expect(onerror).not.toContain("<img"); + expect(onerror).toContain("before"); + expect(onerror).toContain("after"); + + expect( + sanitizeFeedHtml('<a href="javascript:alert(1)">x</a>'), + ).not.toContain("javascript:"); + expect( + sanitizeFeedHtml('<iframe src="https://evil.example.com"></iframe>'), + ).not.toContain("iframe"); + expect( + sanitizeFeedHtml( + '<meta http-equiv="refresh" content="0;url=https://evil.example.com">', + ), + ).not.toContain("meta"); + expect(sanitizeFeedHtml("<style>body{display:none}</style>")).not.toContain( + "style", + ); + expect(sanitizeFeedHtml('<div onclick="steal()">text</div>')).toBe("text"); + }); +}); diff --git a/src/lib/Utility/sanitizeHtml.ts b/src/lib/Utility/sanitizeHtml.ts new file mode 100644 index 00000000..3d0229e4 --- /dev/null +++ b/src/lib/Utility/sanitizeHtml.ts @@ -0,0 +1,32 @@ +import DOMPurify from "dompurify"; + +const feedConfig = { + ALLOWED_TAGS: [ + "a", + "b", + "i", + "em", + "strong", + "u", + "s", + "br", + "p", + "span", + "small", + "sup", + "sub", + "code", + ], + ALLOWED_ATTR: ["href", "title"], + ALLOWED_URI_REGEXP: /^(?:https?|mailto):/i, +}; + +/** + * Sanitise HTML coming from third-party RSS feeds before it reaches an `{@html}` + * sink. Keeps the light formatting these feeds actually use (HTML entities, + * `<i>`/`<b>`/`<a href>`) and strips anything that could inject content or + * behaviour: `<script>`, event-handler attributes, `<iframe>`/`<meta>`/`<style>`, + * `javascript:` URLs, and so on. Browser-only — call it from client code. + */ +export const sanitizeFeedHtml = (html: string | undefined | null): string => + html ? DOMPurify.sanitize(html, feedConfig) : ""; diff --git a/src/routes/updates/+page.svelte b/src/routes/updates/+page.svelte index 357a5906..0534d0bb 100644 --- a/src/routes/updates/+page.svelte +++ b/src/routes/updates/+page.svelte @@ -5,6 +5,7 @@ import HeadTitle from "$lib/Home/HeadTitle.svelte"; import Skeleton from "$lib/Loading/Skeleton.svelte"; import { createHeightObserver } from "$lib/Utility/html"; import root from "$lib/Utility/root"; +import { sanitizeFeedHtml } from "$lib/Utility/sanitizeHtml"; import locale from "$stores/locale"; let feed: @@ -35,10 +36,28 @@ onMount(async () => { removeHeightObserver = createHeightObserver(false); startTime = performance.now(); - novelFeed = await (await fetch(root("/api/updates/all-novels"))).json(); + + const allNovels = await (await fetch(root("/api/updates/all-novels"))).json(); + + if (allNovels?.data?.items) + for (const item of allNovels.data.items) { + if (item.postfix) item.postfix = sanitizeFeedHtml(item.postfix); + if (item.series) item.series.name = sanitizeFeedHtml(item.series.name); + } + + novelFeed = allNovels; novelEndTime = performance.now() - startTime; startTime = performance.now(); - feed = await (await fetch(root("/api/updates/manga"))).json(); + + const mangaFeed = await (await fetch(root("/api/updates/manga"))).json(); + + if (mangaFeed?.items) + for (const item of mangaFeed.items) { + item.title = sanitizeFeedHtml(item.title); + item.content = sanitizeFeedHtml(item.content); + } + + feed = mangaFeed; mangaEndTime = performance.now() - startTime; }); |