fix(SEC-API-17): Block data: URI scheme in markdown renderer
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

Remove data: from allowedSchemesByTag for img tags and add transformTags
filters for both <a> and <img> elements that strip data: URI schemes
(including mixed-case and whitespace-padded variants). This prevents
XSS/CSRF attacks via embedded data URIs in markdown content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-06 13:22:46 -06:00
parent 7f0f7ce484
commit ef1f1eee9d
2 changed files with 66 additions and 4 deletions

View File

@@ -146,13 +146,12 @@ plain text code
expect(html).toContain('alt="Alt text"'); expect(html).toContain('alt="Alt text"');
}); });
it("should allow data URIs for images", async () => { it("should block data URIs for images", async () => {
const markdown = const markdown =
"![Image](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==)"; "![Image](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==)";
const html = await renderMarkdown(markdown); const html = await renderMarkdown(markdown);
expect(html).toContain("<img"); expect(html).not.toContain("data:");
expect(html).toContain('src="data:image/png;base64');
}); });
}); });
@@ -317,6 +316,45 @@ plain text code
expect(html).not.toContain("<svg"); expect(html).not.toContain("<svg");
expect(html).not.toContain("<script>"); expect(html).not.toContain("<script>");
}); });
it("should block data: URI scheme in image src", async () => {
const markdown = "![XSS](data:text/html;base64,PHNjcmlwdD5hbGVydCgnWFNTJyk8L3NjcmlwdD4=)";
const html = await renderMarkdown(markdown);
expect(html).not.toContain("data:");
expect(html).not.toContain("text/html");
});
it("should block data: URI scheme in links", async () => {
const markdown = "[Click me](data:text/html;base64,PHNjcmlwdD5hbGVydCgnWFNTJyk8L3NjcmlwdD4=)";
const html = await renderMarkdown(markdown);
expect(html).not.toContain("data:");
expect(html).not.toContain("text/html");
});
it("should block data: URI with mixed case in images", async () => {
const markdown =
"![XSS](Data:image/svg+xml;base64,PHN2Zz48c2NyaXB0PmFsZXJ0KCdYU1MnKTwvc2NyaXB0Pjwvc3ZnPg==)";
const html = await renderMarkdown(markdown);
expect(html).not.toContain("data:");
expect(html).not.toContain("Data:");
});
it("should block data: URI with leading whitespace", async () => {
const markdown = "![XSS]( data:image/png;base64,abc123)";
const html = await renderMarkdown(markdown);
expect(html).not.toContain("data:");
});
it("should block data: URI in sync renderer", () => {
const markdown = "![XSS](data:image/png;base64,abc123)";
const html = renderMarkdownSync(markdown);
expect(html).not.toContain("data:");
});
}); });
describe("Edge Cases", () => { describe("Edge Cases", () => {

View File

@@ -107,7 +107,7 @@ const SANITIZE_OPTIONS: sanitizeHtml.IOptions = {
}, },
allowedSchemes: ["http", "https", "mailto"], allowedSchemes: ["http", "https", "mailto"],
allowedSchemesByTag: { allowedSchemesByTag: {
img: ["http", "https", "data"], img: ["http", "https"],
}, },
allowedClasses: { allowedClasses: {
code: ["hljs", "language-*"], code: ["hljs", "language-*"],
@@ -115,9 +115,18 @@ const SANITIZE_OPTIONS: sanitizeHtml.IOptions = {
}, },
allowedIframeHostnames: [], // No iframes allowed allowedIframeHostnames: [], // No iframes allowed
// Enforce target="_blank" and rel="noopener noreferrer" for external links // Enforce target="_blank" and rel="noopener noreferrer" for external links
// Block data: URIs in links and images to prevent XSS/CSRF attacks
transformTags: { transformTags: {
a: (tagName: string, attribs: sanitizeHtml.Attributes) => { a: (tagName: string, attribs: sanitizeHtml.Attributes) => {
const href = attribs.href; const href = attribs.href;
// Strip data: URI scheme from links
if (href?.trim().toLowerCase().startsWith("data:")) {
const { href: _removed, ...safeAttribs } = attribs;
return {
tagName,
attribs: safeAttribs,
};
}
if (href && (href.startsWith("http://") || href.startsWith("https://"))) { if (href && (href.startsWith("http://") || href.startsWith("https://"))) {
return { return {
tagName, tagName,
@@ -133,6 +142,21 @@ const SANITIZE_OPTIONS: sanitizeHtml.IOptions = {
attribs, attribs,
}; };
}, },
// Strip data: URI scheme from images to prevent XSS/CSRF
img: (tagName: string, attribs: sanitizeHtml.Attributes) => {
const src = attribs.src;
if (src?.trim().toLowerCase().startsWith("data:")) {
const { src: _removed, ...safeAttribs } = attribs;
return {
tagName,
attribs: safeAttribs,
};
}
return {
tagName,
attribs,
};
},
// Disable task list checkboxes (make them read-only) // Disable task list checkboxes (make them read-only)
input: (tagName: string, attribs: sanitizeHtml.Attributes) => { input: (tagName: string, attribs: sanitizeHtml.Attributes) => {
if (attribs.type === "checkbox") { if (attribs.type === "checkbox") {