Skip to content

Commit 9088bc9

Browse files
committed
fix(blog): sanitize descriptionHtml before set:html
Blog dek content can include intentional HTML links via descriptionHtml. Render through an allowlist sanitizer so script tags, event handlers, and javascript: hrefs cannot execute if introduced via content or a PR. Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
1 parent 67273bf commit 9088bc9

3 files changed

Lines changed: 135 additions & 1 deletion

File tree

src/lib/sanitize-html.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { sanitizeHtml } from "./sanitize-html";
3+
4+
describe("sanitizeHtml", () => {
5+
test("preserves safe anchors and emphasis", () => {
6+
const input =
7+
'Every skill ships with a <a href="https://example.com/card">Skill Card</a> and <em>scans</em>';
8+
expect(sanitizeHtml(input)).toBe(input);
9+
});
10+
11+
test("strips script tags by escaping them", () => {
12+
const input = 'hello <script>alert(1)</script> world';
13+
const out = sanitizeHtml(input);
14+
expect(out).not.toContain("<script>");
15+
expect(out).toContain("&lt;script&gt;");
16+
});
17+
18+
test("drops javascript: hrefs", () => {
19+
const input = '<a href="javascript:alert(1)">x</a>';
20+
const out = sanitizeHtml(input);
21+
expect(out).not.toContain("javascript:");
22+
expect(out).toBe("<a>x</a>");
23+
});
24+
25+
test("adds noopener for target=_blank", () => {
26+
const input = '<a href="https://example.com" target="_blank">x</a>';
27+
const out = sanitizeHtml(input);
28+
expect(out).toContain('rel="noopener noreferrer"');
29+
});
30+
31+
test("escapes event-handler attributes on allowlisted tags", () => {
32+
const input = '<a href="https://example.com" onclick="alert(1)">x</a>';
33+
const out = sanitizeHtml(input);
34+
expect(out).not.toContain("onclick");
35+
expect(out).toBe('<a href="https://example.com">x</a>');
36+
});
37+
});

src/lib/sanitize-html.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* Strip dangerous HTML while preserving safe formatting tags.
3+
* Used by blog descriptionHtml (and similar set:html surfaces).
4+
* Prevents XSS if a malicious string is introduced via content or a PR.
5+
*/
6+
7+
const SAFE_TAGS = new Set([
8+
"strong",
9+
"em",
10+
"b",
11+
"i",
12+
"code",
13+
"br",
14+
"a",
15+
"p",
16+
"span",
17+
"sup",
18+
"sub",
19+
"ul",
20+
"ol",
21+
"li",
22+
]);
23+
24+
const SAFE_ATTRS: Record<string, Set<string>> = {
25+
a: new Set(["href", "title", "target", "rel"]),
26+
span: new Set(["class"]),
27+
};
28+
29+
const SAFE_HREF_RE = /^(https:\/\/|\/|#)/i;
30+
const SAFE_TARGETS = new Set(["_blank", "_self", "_parent", "_top"]);
31+
32+
function escapeHtml(text: string): string {
33+
return text
34+
.replace(/&/g, "&amp;")
35+
.replace(/</g, "&lt;")
36+
.replace(/>/g, "&gt;")
37+
.replace(/"/g, "&quot;");
38+
}
39+
40+
/**
41+
* Sanitize an HTML string: keep only allowlisted tags and attributes,
42+
* escape everything else.
43+
*/
44+
export function sanitizeHtml(raw: string): string {
45+
return raw.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b([^>]*)?\/?>/g, (match, tag, attrStr) => {
46+
const lower = (tag as string).toLowerCase();
47+
if (!SAFE_TAGS.has(lower)) {
48+
return escapeHtml(match);
49+
}
50+
51+
if (match.startsWith("</")) {
52+
return `</${lower}>`;
53+
}
54+
55+
const allowedAttrs = SAFE_ATTRS[lower];
56+
if (!allowedAttrs || !attrStr?.trim()) {
57+
const selfClose = match.endsWith("/>") ? " /" : "";
58+
return `<${lower}${selfClose}>`;
59+
}
60+
61+
const attrs: string[] = [];
62+
let opensNewWindow = false;
63+
let targetSeen = false;
64+
let relSeen = false;
65+
const attrRe = /([a-zA-Z][\w-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g;
66+
let m: RegExpExecArray | null;
67+
while ((m = attrRe.exec(attrStr)) !== null) {
68+
const name = m[1]!.toLowerCase();
69+
const value = m[2] ?? m[3] ?? m[4] ?? "";
70+
if (!allowedAttrs.has(name)) continue;
71+
if (name === "href" && !SAFE_HREF_RE.test(value)) continue;
72+
if (name === "target") {
73+
if (targetSeen) continue;
74+
const target = value.toLowerCase();
75+
if (!SAFE_TARGETS.has(target)) continue;
76+
targetSeen = true;
77+
opensNewWindow = target === "_blank";
78+
attrs.push(`${name}="${target}"`);
79+
continue;
80+
}
81+
if (name === "rel") {
82+
relSeen = true;
83+
attrs.push(`${name}="${escapeHtml(value)}"`);
84+
continue;
85+
}
86+
attrs.push(`${name}="${escapeHtml(value)}"`);
87+
}
88+
if (lower === "a" && opensNewWindow && !relSeen) {
89+
attrs.push('rel="noopener noreferrer"');
90+
}
91+
92+
const selfClose = match.endsWith("/>") ? " /" : "";
93+
const attrPart = attrs.length > 0 ? ` ${attrs.join(" ")}` : "";
94+
return `<${lower}${attrPart}${selfClose}>`;
95+
});
96+
}

src/pages/blog/[...slug].astro

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getPublishedBlogPosts } from '../../lib/blog';
77
import { resolveBlogSocialImage } from '../../lib/blog-social-image';
88
import { authorSlug, getSortedAuthorLinkMeta, resolveAuthorProfile, type BlogAuthor } from '../../lib/authors';
99
import { getCachedXAvatarSrc, getInitialsAvatarSrc } from '../../lib/avatars';
10+
import { sanitizeHtml } from '../../lib/sanitize-html';
1011
import { sanitizeUrl } from '../../lib/sanitize-url';
1112
import { absoluteUrl, blogPostPath } from '../../lib/seo';
1213
import { siX } from 'simple-icons';
@@ -76,7 +77,7 @@ const canonicalPostUrl = absoluteUrl(blogPostPath(post));
7677
const ogImage = resolveBlogSocialImage(post.id, post.data.ogImage);
7778
const articleImageUrl = absoluteUrl(ogImage.src);
7879
79-
const descriptionHtml = post.data.descriptionHtml ?? post.data.description;
80+
const descriptionHtml = sanitizeHtml(post.data.descriptionHtml ?? post.data.description);
8081
const structuredAuthors = authors.map((author) => {
8182
const sameAs = getSortedAuthorLinkMeta(author)
8283
.map((link) => link.url)

0 commit comments

Comments
 (0)