From 9d7afa7298106e3bff3d5d29c186612d988560b2 Mon Sep 17 00:00:00 2001
From: "google-labs-jules[bot]"
<161369871+google-labs-jules[bot]@users.noreply.github.com>
Date: Sat, 1 Aug 2026 10:55:54 +0000
Subject: [PATCH] fix(security): replace unsafe @html tag with a secure
dompurify action
Co-authored-by: Sparkier <5690524+Sparkier@users.noreply.github.com>
---
src/lib/actions/renderSanitizedHtml.spec.ts | 25 +++++++++++++++++++++
src/lib/actions/renderSanitizedHtml.ts | 14 ++++++++++++
src/routes/blog/[entry]/+page.svelte | 10 ++++-----
3 files changed, 43 insertions(+), 6 deletions(-)
create mode 100644 src/lib/actions/renderSanitizedHtml.spec.ts
create mode 100644 src/lib/actions/renderSanitizedHtml.ts
diff --git a/src/lib/actions/renderSanitizedHtml.spec.ts b/src/lib/actions/renderSanitizedHtml.spec.ts
new file mode 100644
index 0000000..e35c358
--- /dev/null
+++ b/src/lib/actions/renderSanitizedHtml.spec.ts
@@ -0,0 +1,25 @@
+import { renderSanitizedHtml } from './renderSanitizedHtml';
+import { describe, it, expect, beforeEach } from 'vitest';
+
+describe('renderSanitizedHtml action', () => {
+ let node: HTMLElement;
+
+ beforeEach(() => {
+ node = document.createElement('div');
+ });
+
+ it('should sanitize and render HTML', () => {
+ renderSanitizedHtml(node, '
Hello
');
+ expect(node.innerHTML).toBe('Hello
');
+ });
+
+ it('should update the HTML', () => {
+ const action = renderSanitizedHtml(node, 'Hello
');
+ expect(node.innerHTML).toBe('Hello
');
+
+ if (action && action.update) {
+ action.update('World
');
+ }
+ expect(node.innerHTML).toBe('World
');
+ });
+});
diff --git a/src/lib/actions/renderSanitizedHtml.ts b/src/lib/actions/renderSanitizedHtml.ts
new file mode 100644
index 0000000..447bb7a
--- /dev/null
+++ b/src/lib/actions/renderSanitizedHtml.ts
@@ -0,0 +1,14 @@
+import purify from 'isomorphic-dompurify';
+import type { Action } from 'svelte/action';
+
+export const renderSanitizedHtml: Action = (node, html) => {
+ const fragment = purify.sanitize(html, { RETURN_DOM_FRAGMENT: true });
+ node.replaceChildren(fragment);
+
+ return {
+ update(newHtml: string) {
+ const fragment = purify.sanitize(newHtml, { RETURN_DOM_FRAGMENT: true });
+ node.replaceChildren(fragment);
+ }
+ };
+};
diff --git a/src/routes/blog/[entry]/+page.svelte b/src/routes/blog/[entry]/+page.svelte
index 10a0362..f323494 100644
--- a/src/routes/blog/[entry]/+page.svelte
+++ b/src/routes/blog/[entry]/+page.svelte
@@ -2,9 +2,9 @@
import { page } from '$app/stores';
import { blogEntries } from '$lib/helpers/blogProvider';
import type { BlogEntry } from '$lib/types';
- import purify from 'isomorphic-dompurify';
import { parse } from 'marked';
import { reveal } from '$lib/actions/reveal';
+ import { renderSanitizedHtml } from '$lib/actions/renderSanitizedHtml';
let content = $state('');
@@ -17,7 +17,7 @@
async function loadContent(entry: BlogEntry | undefined) {
if (!entry) return;
const response = await fetch(`/blog_md/${entry.content_md}`);
- content = purify.sanitize(await parse(await response.text()));
+ content = await parse(await response.text());
}
@@ -62,9 +62,7 @@
prose-code:bg-background-card prose-code:px-1.5
prose-code:py-0.5 prose-code:text-sm prose-code:text-text prose-ol:text-text-muted prose-ul:text-text-muted prose-li:marker:text-primary/40
[&_a]:break-all [&_p]:whitespace-pre-wrap"
- >
-
- {@html content}
-
+ use:renderSanitizedHtml={content}
+ >