diff --git a/apps/web/app/blog/[slug]/page.tsx b/apps/web/app/blog/[slug]/page.tsx
index 0e2989c..f3154cf 100644
--- a/apps/web/app/blog/[slug]/page.tsx
+++ b/apps/web/app/blog/[slug]/page.tsx
@@ -5,6 +5,7 @@ import { notFound } from 'next/navigation';
import Image from 'next/image';
import Link from 'next/link';
import { getDb } from '@/lib/db';
+import { sanitizeBlogHtml } from '@/lib/sanitize-blog-html';
interface Post {
id: number;
@@ -61,6 +62,7 @@ export default async function BlogPostPage({ params }: { params: Promise<{ slug:
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
+ const sanitizedContent = sanitizeBlogHtml(post.content);
return (
@@ -93,7 +95,7 @@ export default async function BlogPostPage({ params }: { params: Promise<{ slug:
diff --git a/apps/web/lib/sanitize-blog-html.ts b/apps/web/lib/sanitize-blog-html.ts
new file mode 100644
index 0000000..ea0191c
--- /dev/null
+++ b/apps/web/lib/sanitize-blog-html.ts
@@ -0,0 +1,28 @@
+import sanitizeHtml from 'sanitize-html';
+
+const allowedTags = [
+ ...sanitizeHtml.defaults.allowedTags,
+ 'figure',
+ 'figcaption',
+ 'img',
+];
+
+export function sanitizeBlogHtml(content: string): string {
+ return sanitizeHtml(content, {
+ allowedTags,
+ allowedAttributes: {
+ ...sanitizeHtml.defaults.allowedAttributes,
+ a: ['href', 'name', 'target', 'title', 'rel'],
+ img: ['src', 'alt', 'title', 'width', 'height', 'loading'],
+ code: ['class'],
+ pre: ['class'],
+ },
+ allowedSchemes: ['http', 'https', 'mailto', 'tel'],
+ allowedSchemesAppliedToAttributes: ['href', 'src'],
+ transformTags: {
+ a: sanitizeHtml.simpleTransform('a', {
+ rel: 'noopener noreferrer',
+ }, true),
+ },
+ });
+}
diff --git a/apps/web/package.json b/apps/web/package.json
index 5e52dcd..3a47b50 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -21,6 +21,7 @@
"posthog-js": "^1.381.0",
"react": "19.2.4",
"react-dom": "19.2.4",
+ "sanitize-html": "^2.17.5",
"serwist": "^9.5.0"
},
"devDependencies": {
@@ -28,6 +29,7 @@
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "@types/sanitize-html": "^2.16.1",
"eslint": "^9",
"eslint-config-next": "16.2.7",
"tailwindcss": "^4",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2c493b9..160ef6b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -66,6 +66,9 @@ importers:
react-dom:
specifier: 19.2.4
version: 19.2.4(react@19.2.4)
+ sanitize-html:
+ specifier: ^2.17.5
+ version: 2.17.5
serwist:
specifier: ^9.5.0
version: 9.5.11(browserslist@4.28.2)(typescript@5.9.3)
@@ -82,6 +85,9 @@ importers:
'@types/react-dom':
specifier: ^19
version: 19.2.3(@types/react@19.2.16)
+ '@types/sanitize-html':
+ specifier: ^2.16.1
+ version: 2.16.1
eslint:
specifier: ^9
version: 9.39.4(jiti@2.7.0)
@@ -861,6 +867,9 @@ packages:
'@types/react@19.2.16':
resolution: {integrity: sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==}
+ '@types/sanitize-html@2.16.1':
+ resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==}
+
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
@@ -1230,6 +1239,9 @@ packages:
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
engines: {node: '>= 0.4'}
+ dayjs@1.11.21:
+ resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==}
+
debug@3.2.7:
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
peerDependencies:
@@ -1250,6 +1262,10 @@ packages:
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
+ deepmerge@4.3.1:
+ resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
+ engines: {node: '>=0.10.0'}
+
define-data-property@1.1.4:
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
engines: {node: '>= 0.4'}
@@ -1266,9 +1282,22 @@ packages:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
+ dom-serializer@2.0.0:
+ resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
+
+ domelementtype@2.3.0:
+ resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
+
+ domhandler@5.0.3:
+ resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
+ engines: {node: '>= 4'}
+
dompurify@3.4.8:
resolution: {integrity: sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==}
+ domutils@3.2.2:
+ resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
+
dotenv@16.6.1:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
@@ -1297,6 +1326,14 @@ packages:
resolution: {integrity: sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag==}
engines: {node: '>=10.13.0'}
+ entities@4.5.0:
+ resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
+ engines: {node: '>=0.12'}
+
+ entities@7.0.1:
+ resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
+ engines: {node: '>=0.12'}
+
es-abstract@1.24.2:
resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==}
engines: {node: '>= 0.4'}
@@ -1625,6 +1662,9 @@ packages:
hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
+ htmlparser2@10.1.0:
+ resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
+
idb@8.0.3:
resolution: {integrity: sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==}
@@ -1722,6 +1762,10 @@ packages:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
+ is-plain-object@5.0.0:
+ resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
+ engines: {node: '>=0.10.0'}
+
is-regex@1.2.1:
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
engines: {node: '>= 0.4'}
@@ -1831,6 +1875,9 @@ packages:
resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==}
engines: {node: '>=0.10'}
+ launder@1.7.1:
+ resolution: {integrity: sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==}
+
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@@ -2071,6 +2118,9 @@ packages:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
+ parse-srcset@1.0.2:
+ resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
+
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
@@ -2191,6 +2241,9 @@ packages:
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
engines: {node: '>= 0.4'}
+ sanitize-html@2.17.5:
+ resolution: {integrity: sha512-ZmU1joGRrvoyctKIiuwUxqR6moLoU2Wk+2bMccN6f7UwhAmwYDvWziqPxRDDN2Qip62NqnIrVrT9akbL6Wretg==}
+
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -3165,6 +3218,10 @@ snapshots:
dependencies:
csstype: 3.2.3
+ '@types/sanitize-html@2.16.1':
+ dependencies:
+ htmlparser2: 10.1.0
+
'@types/trusted-types@2.0.7': {}
'@typescript-eslint/eslint-plugin@8.60.1(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
@@ -3544,6 +3601,8 @@ snapshots:
es-errors: 1.3.0
is-data-view: 1.0.2
+ dayjs@1.11.21: {}
+
debug@3.2.7:
dependencies:
ms: 2.1.3
@@ -3554,6 +3613,8 @@ snapshots:
deep-is@0.1.4: {}
+ deepmerge@4.3.1: {}
+
define-data-property@1.1.4:
dependencies:
es-define-property: 1.0.1
@@ -3572,10 +3633,28 @@ snapshots:
dependencies:
esutils: 2.0.3
+ dom-serializer@2.0.0:
+ dependencies:
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+ entities: 4.5.0
+
+ domelementtype@2.3.0: {}
+
+ domhandler@5.0.3:
+ dependencies:
+ domelementtype: 2.3.0
+
dompurify@3.4.8:
optionalDependencies:
'@types/trusted-types': 2.0.7
+ domutils@3.2.2:
+ dependencies:
+ dom-serializer: 2.0.0
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+
dotenv@16.6.1: {}
dunder-proto@1.0.1:
@@ -3609,6 +3688,10 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.3
+ entities@4.5.0: {}
+
+ entities@7.0.1: {}
+
es-abstract@1.24.2:
dependencies:
array-buffer-byte-length: 1.0.2
@@ -4105,6 +4188,13 @@ snapshots:
dependencies:
hermes-estree: 0.25.1
+ htmlparser2@10.1.0:
+ dependencies:
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+ domutils: 3.2.2
+ entities: 7.0.1
+
idb@8.0.3: {}
ieee754@1.2.1: {}
@@ -4201,6 +4291,8 @@ snapshots:
is-number@7.0.0: {}
+ is-plain-object@5.0.0: {}
+
is-regex@1.2.1:
dependencies:
call-bound: 1.0.4
@@ -4303,6 +4395,10 @@ snapshots:
dependencies:
language-subtag-registry: 0.3.23
+ launder@1.7.1:
+ dependencies:
+ dayjs: 1.11.21
+
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
@@ -4535,6 +4631,8 @@ snapshots:
dependencies:
callsites: 3.1.0
+ parse-srcset@1.0.2: {}
+
path-exists@4.0.0: {}
path-key@3.1.1: {}
@@ -4667,6 +4765,16 @@ snapshots:
es-errors: 1.3.0
is-regex: 1.2.1
+ sanitize-html@2.17.5:
+ dependencies:
+ deepmerge: 4.3.1
+ 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.5.15
+
scheduler@0.27.0: {}
semver@6.3.1: {}