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
});
}

236
package-lock.json generated
View file

@ -134,13 +134,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"
}
@ -960,9 +962,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -979,9 +978,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -998,9 +994,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -1017,9 +1010,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -1036,9 +1026,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -1055,9 +1042,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -1074,9 +1058,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -1093,9 +1074,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -1112,9 +1090,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1137,9 +1112,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1162,9 +1134,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1187,9 +1156,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1212,9 +1178,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1237,9 +1200,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1262,9 +1222,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1287,9 +1244,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1609,9 +1563,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1628,9 +1579,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1647,9 +1595,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1666,9 +1611,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1846,9 +1788,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1865,9 +1804,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1884,9 +1820,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1903,9 +1836,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1922,9 +1852,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1941,9 +1868,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -2110,9 +2034,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -2126,9 +2047,6 @@
"cpu": [
"arm"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -2142,9 +2060,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -2158,9 +2073,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -2174,9 +2086,6 @@
"cpu": [
"loong64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -2190,9 +2099,6 @@
"cpu": [
"loong64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -2206,9 +2112,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -2222,9 +2125,6 @@
"cpu": [
"ppc64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -2238,9 +2138,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -2254,9 +2151,6 @@
"cpu": [
"riscv64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -2270,9 +2164,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -2286,9 +2177,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -2302,9 +2190,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -2601,6 +2486,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/sanitize-html": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.1.tgz",
"integrity": "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==",
"dev": true,
"license": "MIT",
"dependencies": {
"htmlparser2": "^10.1"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
@ -3102,6 +2997,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/dayjs": {
"version": "1.11.21",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -3119,6 +3020,15 @@
}
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@ -3357,6 +3267,18 @@
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
@ -3798,6 +3720,15 @@
"node": ">= 0.10"
}
},
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@ -3861,6 +3792,15 @@
"node": ">=6"
}
},
"node_modules/launder": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz",
"integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==",
"license": "MIT",
"dependencies": {
"dayjs": "^1.11.7"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@ -3997,9 +3937,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -4020,9 +3957,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -4043,9 +3977,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -4066,9 +3997,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -4399,6 +4327,12 @@
"wrappy": "1"
}
},
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
"license": "MIT"
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
@ -4561,6 +4495,7 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@ -4826,6 +4761,21 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sanitize-html": {
"version": "2.17.5",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.5.tgz",
"integrity": "sha512-ZmU1joGRrvoyctKIiuwUxqR6moLoU2Wk+2bMccN6f7UwhAmwYDvWziqPxRDDN2Qip62NqnIrVrT9akbL6Wretg==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^10.1.0",
"is-plain-object": "^5.0.0",
"launder": "^1.7.1",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@ -5215,6 +5165,7 @@
"os": [
"aix"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -5231,6 +5182,7 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -5247,6 +5199,7 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -5263,6 +5216,7 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -5279,6 +5233,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -5295,6 +5250,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -5311,6 +5267,7 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -5327,6 +5284,7 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -5343,6 +5301,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -5359,6 +5318,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -5375,6 +5335,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -5391,6 +5352,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -5407,6 +5369,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -5423,6 +5386,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -5439,6 +5403,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -5455,6 +5420,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -5471,6 +5437,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -5487,6 +5454,7 @@
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -5503,6 +5471,7 @@
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -5519,6 +5488,7 @@
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -5535,6 +5505,7 @@
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -5551,6 +5522,7 @@
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -5567,6 +5539,7 @@
"os": [
"sunos"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -5583,6 +5556,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -5599,6 +5573,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@ -5615,6 +5590,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}