aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/lib/Utility/sanitizeHtml.test.ts57
-rw-r--r--src/lib/Utility/sanitizeHtml.ts32
-rw-r--r--src/routes/updates/+page.svelte23
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;
});