From ee462dd70db3b2c67a20b52dec6c1419e3c67f83 Mon Sep 17 00:00:00 2001 From: Samuel Painter Date: Wed, 22 Apr 2026 01:53:03 -0700 Subject: [PATCH] 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 --- .../src/core/export-html/template.js | 30 +++++++++++++++++-- .../coding-agent/test/export-html-xss.test.ts | 28 +++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 packages/coding-agent/test/export-html-xss.test.ts diff --git a/packages/coding-agent/src/core/export-html/template.js b/packages/coding-agent/src/core/export-html/template.js index ff9e4641..3f4c40f6 100644 --- a/packages/coding-agent/src/core/export-html/template.js +++ b/packages/coding-agent/src/core/export-html/template.js @@ -881,7 +881,7 @@ const images = getResultImages(); if (images.length === 0) return ''; return '
' + - images.map(img => ``).join('') + + images.map(img => ``).join('') + '
'; }; @@ -1148,7 +1148,7 @@ if (images.length > 0) { html += '
'; for (const img of images) { - html += ``; + html += ``; } html += '
'; } @@ -1491,6 +1491,32 @@ } }, 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 = ''; + 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 = '' + escapeHtml(token.text || '') + ' { + 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/); + }); +});