fix(coding-agent): sanitize markdown links in exported session HTML (#3532)
marked v15 does not filter dangerous URL protocols. The default link renderer passes href values through verbatim, so markdown like `[click](javascript:alert(1))` renders as a clickable XSS link in shared/exported session HTML. Add custom link and image renderers that: - Block javascript:, vbscript:, and data: protocol URLs - Escape href/title/alt attributes via escapeHtml() Also escape img.mimeType in session image rendering to prevent attribute breakout from crafted session JSONL. Fixes #3531
This commit is contained in:
@@ -881,7 +881,7 @@
|
|||||||
const images = getResultImages();
|
const images = getResultImages();
|
||||||
if (images.length === 0) return '';
|
if (images.length === 0) return '';
|
||||||
return '<div class="tool-images">' +
|
return '<div class="tool-images">' +
|
||||||
images.map(img => `<img src="data:${img.mimeType};base64,${img.data}" class="tool-image" />`).join('') +
|
images.map(img => `<img src="data:${escapeHtml(img.mimeType || 'image/png')};base64,${img.data}" class="tool-image" />`).join('') +
|
||||||
'</div>';
|
'</div>';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1148,7 +1148,7 @@
|
|||||||
if (images.length > 0) {
|
if (images.length > 0) {
|
||||||
html += '<div class="message-images">';
|
html += '<div class="message-images">';
|
||||||
for (const img of images) {
|
for (const img of images) {
|
||||||
html += `<img src="data:${img.mimeType};base64,${img.data}" class="message-image" />`;
|
html += `<img src="data:${escapeHtml(img.mimeType || 'image/png')};base64,${img.data}" class="message-image" />`;
|
||||||
}
|
}
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
}
|
}
|
||||||
@@ -1491,6 +1491,32 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
renderer: {
|
renderer: {
|
||||||
|
// Sanitize link URLs to prevent javascript:/vbscript:/data: XSS
|
||||||
|
link(token) {
|
||||||
|
const href = (token.href || '').trim();
|
||||||
|
if (/^\s*(javascript|vbscript|data):/i.test(href)) {
|
||||||
|
return this.parser.parseInline(token.tokens);
|
||||||
|
}
|
||||||
|
let out = '<a href="' + escapeHtml(href) + '"';
|
||||||
|
if (token.title) {
|
||||||
|
out += ' title="' + escapeHtml(token.title) + '"';
|
||||||
|
}
|
||||||
|
out += '>' + this.parser.parseInline(token.tokens) + '</a>';
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
// Sanitize image src URLs
|
||||||
|
image(token) {
|
||||||
|
const href = (token.href || '').trim();
|
||||||
|
if (/^\s*(javascript|vbscript|data):/i.test(href)) {
|
||||||
|
return escapeHtml(token.text || '');
|
||||||
|
}
|
||||||
|
let out = '<img src="' + escapeHtml(href) + '" alt="' + escapeHtml(token.text || '') + '"';
|
||||||
|
if (token.title) {
|
||||||
|
out += ' title="' + escapeHtml(token.title) + '"';
|
||||||
|
}
|
||||||
|
out += '>';
|
||||||
|
return out;
|
||||||
|
},
|
||||||
// Code blocks: syntax highlight, no HTML escaping
|
// Code blocks: syntax highlight, no HTML escaping
|
||||||
code(token) {
|
code(token) {
|
||||||
const code = token.text;
|
const code = token.text;
|
||||||
|
|||||||
28
packages/coding-agent/test/export-html-xss.test.ts
Normal file
28
packages/coding-agent/test/export-html-xss.test.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("export HTML markdown link sanitization", () => {
|
||||||
|
const templateJs = readFileSync(new URL("../src/core/export-html/template.js", import.meta.url), "utf-8");
|
||||||
|
|
||||||
|
it("overrides the marked link renderer to block javascript: protocol", () => {
|
||||||
|
// The custom link renderer must check for dangerous protocols
|
||||||
|
expect(templateJs).toMatch(/link\s*\(\s*token\s*\)/);
|
||||||
|
expect(templateJs).toMatch(/javascript/i);
|
||||||
|
expect(templateJs).toMatch(/vbscript/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("overrides the marked image renderer to block javascript: protocol", () => {
|
||||||
|
expect(templateJs).toMatch(/image\s*\(\s*token\s*\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes href attributes in the custom link renderer", () => {
|
||||||
|
// The link renderer must escape href values to prevent attribute breakout
|
||||||
|
expect(templateJs).toMatch(/escapeHtml\(href\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes image mimeType attributes", () => {
|
||||||
|
// Image mimeType must be escaped to prevent attribute breakout
|
||||||
|
expect(templateJs).not.toMatch(/\$\{img\.mimeType\}/);
|
||||||
|
expect(templateJs).toMatch(/escapeHtml\(img\.mimeType/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user