Skip to content

Commit 8f5cccd

Browse files
authored
security: template README HTML rendering (#1617)
* feat: Add HTML sanitization to markdown processing and implement tests * fix: Harden template README link sanitization
1 parent 25294fa commit 8f5cccd

5 files changed

Lines changed: 277 additions & 2 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { markdownToHtml } from "../src/lib/markdown-to-html";
4+
5+
describe("markdownToHtml", () => {
6+
it("strips executable HTML from template README content", async () => {
7+
const html = await markdownToHtml(`
8+
# Safe heading
9+
10+
<img src=x onerror="alert(1)">
11+
<img src="data:image/svg+xml,<svg onload=alert(1)>">
12+
<a href="javascript:alert(1)" onclick="alert(1)">bad</a>
13+
<script>alert(1)</script>
14+
`);
15+
16+
expect(html).toContain("<h1>Safe heading</h1>");
17+
expect(html).not.toContain("<script");
18+
expect(html).not.toContain("onerror");
19+
expect(html).not.toContain("onclick");
20+
expect(html).not.toContain("data:image");
21+
expect(html).not.toContain("javascript:");
22+
expect(html).toContain("<a>bad</a>");
23+
});
24+
25+
it("preserves normal README markdown and safe links", async () => {
26+
const html = await markdownToHtml(`
27+
## Install
28+
29+
Use **pnpm** and visit [Solana](https://solana.com).
30+
31+
![Logo](./logo.png)
32+
33+
- Create a wallet
34+
- Run the app
35+
`);
36+
37+
expect(html).toContain("<h2>Install</h2>");
38+
expect(html).toContain("<strong>pnpm</strong>");
39+
expect(html).toContain('<a href="https://solana.com">Solana</a>');
40+
expect(html).toContain('<img src="./logo.png" alt="Logo" />');
41+
expect(html).toContain("<ul>");
42+
expect(html).toContain("<li>Create a wallet</li>");
43+
});
44+
45+
it("adds noopener and noreferrer to target blank links", async () => {
46+
const html = await markdownToHtml(`
47+
<a href="https://example.com" target="_blank">external</a>
48+
<a href="https://solana.com" target="_blank" rel="nofollow">solana</a>
49+
`);
50+
51+
expect(html).toContain(
52+
'<a href="https://example.com" target="_blank" rel="noopener noreferrer">external</a>',
53+
);
54+
expect(html).toContain(
55+
'<a href="https://solana.com" target="_blank" rel="nofollow noopener noreferrer">solana</a>',
56+
);
57+
});
58+
59+
it("keeps Shiki highlighted code markup without placeholder data", async () => {
60+
const html = await markdownToHtml(`
61+
\`\`\`ts
62+
const answer: number = 42;
63+
\`\`\`
64+
`);
65+
66+
expect(html).toContain("<pre");
67+
expect(html).toContain("shiki");
68+
expect(html).toContain("<code>");
69+
expect(html).toContain("answer");
70+
expect(html).not.toContain("data-code-id");
71+
expect(html).not.toContain("data-code=");
72+
});
73+
});

apps/templates/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"lint": "pnpm exec eslint . && pnpm -w exec prettier --ignore-path .prettierignore --check apps/templates",
1010
"lint:fix": "pnpm exec eslint --fix . && pnpm -w exec prettier --ignore-path .prettierignore --write apps/templates",
1111
"check-types": "tsc --noEmit",
12+
"test": "vitest run",
1213
"i18n:lingo": "pnpm --dir ../.. i18n:app templates",
1314
"clean": "rm -rf node_modules .next"
1415
},
@@ -30,6 +31,7 @@
3031
"react-dom": "catalog:",
3132
"react-feather": "^2.0.10",
3233
"rtl-detect": "^1.1.2",
34+
"sanitize-html": "^2.17.5",
3335
"shiki": "^3.13.0",
3436
"tailwind-merge": "^3.3.1",
3537
"tailwindcss-animate": "^1.0.7"
@@ -40,13 +42,15 @@
4042
"@types/react": "^19.0.14",
4143
"@types/react-dom": "^19.0.6",
4244
"@types/rtl-detect": "^1.0.3",
45+
"@types/sanitize-html": "^2.16.1",
4346
"@workspace/config-eslint": "workspace:*",
4447
"@workspace/config-typescript": "workspace:*",
4548
"autoprefixer": "^10.4.27",
4649
"postcss": "^8.4.49",
4750
"sass": "^1.92.1",
4851
"tailwindcss": "^3.4.19",
49-
"typescript": "5.8.3"
52+
"typescript": "5.8.3",
53+
"vitest": "catalog:"
5054
},
5155
"packageManager": "pnpm@10.15.1"
5256
}

apps/templates/src/lib/markdown-to-html.ts

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { marked } from "marked";
2+
import sanitizeHtml from "sanitize-html";
23
import { codeToHtml } from "shiki";
34

45
/**
@@ -15,6 +16,116 @@ const renderer = {
1516

1617
marked.use({ renderer });
1718

19+
const ALLOWED_TAGS = [
20+
"a",
21+
"blockquote",
22+
"br",
23+
"code",
24+
"dd",
25+
"del",
26+
"details",
27+
"div",
28+
"dl",
29+
"dt",
30+
"em",
31+
"h1",
32+
"h2",
33+
"h3",
34+
"h4",
35+
"h5",
36+
"h6",
37+
"hr",
38+
"img",
39+
"kbd",
40+
"li",
41+
"ol",
42+
"p",
43+
"pre",
44+
"s",
45+
"span",
46+
"strong",
47+
"sub",
48+
"summary",
49+
"sup",
50+
"table",
51+
"tbody",
52+
"td",
53+
"th",
54+
"thead",
55+
"tr",
56+
"ul",
57+
];
58+
59+
const ALLOWED_ATTRIBUTES: sanitizeHtml.IOptions["allowedAttributes"] = {
60+
a: ["href", "name", "rel", "target", "title"],
61+
code: ["class", "style"],
62+
div: ["class"],
63+
img: ["alt", "height", "src", "title", "width"],
64+
pre: ["class", "style", "tabindex"],
65+
span: ["class", "style"],
66+
td: ["align", "colspan", "rowspan"],
67+
th: ["align", "colspan", "rowspan"],
68+
};
69+
70+
const ALLOWED_STYLES: sanitizeHtml.IOptions["allowedStyles"] = {
71+
"*": {
72+
"background-color": [
73+
/^#[0-9a-f]{3,8}$/i,
74+
/^rgb(a)?\([\d\s,%.]+\)$/i,
75+
/^hsl(a)?\([\d\s,%.]+\)$/i,
76+
],
77+
color: [
78+
/^#[0-9a-f]{3,8}$/i,
79+
/^rgb(a)?\([\d\s,%.]+\)$/i,
80+
/^hsl(a)?\([\d\s,%.]+\)$/i,
81+
],
82+
"font-style": [/^italic$/, /^normal$/],
83+
"font-weight": [
84+
/^(?:[1-9]\d{0,2}|1000)$/,
85+
/^bold$/,
86+
/^bolder$/,
87+
/^lighter$/,
88+
/^normal$/,
89+
],
90+
"text-decoration": [/^underline$/, /^none$/],
91+
},
92+
};
93+
94+
function ensureNoopenerNoreferrer(rel: string | undefined): string {
95+
const tokens = new Set((rel ?? "").split(/\s+/).filter(Boolean));
96+
tokens.add("noopener");
97+
tokens.add("noreferrer");
98+
99+
return Array.from(tokens).join(" ");
100+
}
101+
102+
const SANITIZE_OPTIONS: sanitizeHtml.IOptions = {
103+
allowedTags: ALLOWED_TAGS,
104+
allowedAttributes: ALLOWED_ATTRIBUTES,
105+
allowedSchemes: ["http", "https", "mailto", "tel"],
106+
allowedSchemesByTag: {
107+
img: ["http", "https"],
108+
},
109+
allowedStyles: ALLOWED_STYLES,
110+
allowProtocolRelative: false,
111+
disallowedTagsMode: "discard",
112+
transformTags: {
113+
a: (tagName, attribs) => {
114+
if (attribs.target?.toLowerCase() !== "_blank") {
115+
return { tagName, attribs };
116+
}
117+
118+
return {
119+
tagName,
120+
attribs: {
121+
...attribs,
122+
rel: ensureNoopenerNoreferrer(attribs.rel),
123+
},
124+
};
125+
},
126+
},
127+
};
128+
18129
/**
19130
* Convert markdown to HTML with syntax highlighting
20131
*/
@@ -61,5 +172,5 @@ export async function markdownToHtml(markdown: string): Promise<string> {
61172
}
62173
}
63174

64-
return result;
175+
return sanitizeHtml(result, SANITIZE_OPTIONS);
65176
}

apps/templates/vitest.config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import path from "node:path";
2+
import { defineConfig } from "vitest/config";
3+
4+
export default defineConfig({
5+
test: {
6+
environment: "node",
7+
include: ["__tests__/**/*.test.ts"],
8+
alias: {
9+
"@/": `${path.resolve(__dirname, "./src")}/`,
10+
},
11+
},
12+
});

0 commit comments

Comments
 (0)