fix(SEC-API-17): Block data: URI scheme in markdown renderer
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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:
@@ -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 =
|
||||||
"";
|
"";
|
||||||
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 = "";
|
||||||
|
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 =
|
||||||
|
"";
|
||||||
|
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 = "";
|
||||||
|
const html = await renderMarkdown(markdown);
|
||||||
|
|
||||||
|
expect(html).not.toContain("data:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should block data: URI in sync renderer", () => {
|
||||||
|
const markdown = "";
|
||||||
|
const html = renderMarkdownSync(markdown);
|
||||||
|
|
||||||
|
expect(html).not.toContain("data:");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Edge Cases", () => {
|
describe("Edge Cases", () => {
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
Reference in New Issue
Block a user