Sanitize rendered web content (#77)

This commit is contained in:
phucnguyen1707 2026-06-15 15:34:44 +07:00 committed by GitHub
parent cb192906bd
commit 59013cf0af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 167 additions and 133 deletions

View file

@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { sanitizeRenderedHtml } from "@/lib/html";
describe("sanitizeRenderedHtml", () => {
it("removes script tags, event handlers, and javascript URLs", () => {
const html = sanitizeRenderedHtml(
'<h2>Title</h2><p>Hello</p><script>alert(1)</script><img src="https://example.com/a.png" onerror="alert(1)"><a href="javascript:alert(1)">bad</a>'
);
expect(html).toContain("<h2>Title</h2>");
expect(html).toContain("<p>Hello</p>");
expect(html).toContain('<img src="https://example.com/a.png" />');
expect(html).toContain("<a>bad</a>");
expect(html).not.toContain("<script>");
expect(html).not.toContain("onerror");
expect(html).not.toContain("javascript:");
});
});

View file

@ -17,13 +17,15 @@
"marked": "^18.0.5",
"next": "16.2.6",
"react": "19.2.0",
"react-dom": "19.2.0"
"react-dom": "19.2.0",
"sanitize-html": "^2.17.5"
},
"devDependencies": {
"@playwright/test": "^1.57.0",
"@types/node": "^24.10.1",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@types/sanitize-html": "^2.16.1",
"typescript": "^5.9.3",
"vitest": "^4.0.8"
}

View file

@ -4,6 +4,7 @@ import type { ReactNode } from "react";
import type { Metadata } from "next";
import { publicClient } from "@/lib/supabase";
import { SiteShell } from "@/components/site-shell";
import { sanitizeRenderedHtml } from "@/lib/html";
export const dynamic = "force-dynamic";
@ -84,6 +85,7 @@ export default async function BlogPostPage({
mainEntityOfPage: `${SITE_URL}/blog/${post.slug}`,
publisher: { "@id": `${SITE_URL}/#organization` },
};
const html = sanitizeRenderedHtml(post.html);
return (
<SiteShell active="Blog">
@ -114,7 +116,7 @@ export default async function BlogPostPage({
<div
className="blog-content"
style={{ lineHeight: 1.7 }}
dangerouslySetInnerHTML={{ __html: post.html }}
dangerouslySetInnerHTML={{ __html: html }}
/>
</article>
</SiteShell>

View file

@ -5,6 +5,7 @@ import type { Metadata } from "next";
import { marked } from "marked";
import { DOC_SLUGS, docExcerpt, docTitle, readDoc } from "@/lib/docs";
import { SiteShell } from "@/components/site-shell";
import { sanitizeRenderedHtml } from "@/lib/html";
// Statically generate one page per curated doc at build time.
export function generateStaticParams(): Array<{ slug: string }> {
@ -37,7 +38,8 @@ export default async function DocPage({
const md = readDoc(slug);
if (!md) notFound();
const html = await marked.parse(md);
const rawHtml = await marked.parse(md);
const html = sanitizeRenderedHtml(rawHtml);
return (
<SiteShell active="Docs">

View file

@ -0,0 +1,33 @@
import sanitizeHtml from "sanitize-html";
const allowedTags = sanitizeHtml.defaults.allowedTags.concat([
"figure",
"figcaption",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"img"
]);
const allowedAttributes = {
...sanitizeHtml.defaults.allowedAttributes,
a: ["href", "name", "target", "rel"],
img: ["src", "alt", "title", "width", "height", "loading"],
code: ["class"],
pre: ["class"]
};
export function sanitizeRenderedHtml(html: string): string {
return sanitizeHtml(html, {
allowedTags,
allowedAttributes,
allowedSchemes: ["http", "https", "mailto", "tel"],
allowedSchemesByTag: {
img: ["http", "https"]
},
allowProtocolRelative: false
});
}