OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note commits / 16e3018

markdown: fix two stored-XSS vectors in the SSR renderer
Note content is author-controlled and rendered into public SSR pages, so the markdown renderer is a stored-XSS sink: 1. Link href bypassed safeUrl. esc() escapes <>&" but NOT the javascript:/ data:/vbscript: SCHEME, and the [text](url) → <a href=…> path (unlike images) never called safeUrl. `[x](javascript:alert(1))` executed on click. Now routed through safeUrl. 2. Raw <video>/<audio> blocks were passed through verbatim, so `<video onloadstart="alert(1)">` executed. Now we extract only the source URLs (via safeUrl), drop ALL attributes, and re-emit a clean `<video controls>`/`<audio controls>`. Note: the inline <img alt/src> path is already safe — inline() pre-escapes the whole line (line ~190) and img src already goes through safeUrl. CSP intentionally deferred: a wrong policy would break the live SPA; needs a frontend audit. These fixes close the actual stored-XSS holes directly. Test: javascript:/data:/vbscript: links neutralized, raw <video> handlers stripped, malicious <source> filtered, safe links/images preserved. 6/6 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
16e30181520bf97abe98902129e7efcffb94b24c
by plankatron <fleeky@gmail.com> · 2026-06-23T20:13:22.000Z

Changed files (2) +58 -4

lib/markdown.js +20 -4
……@@ -150,7 +150,9 @@ export function renderMarkdown (src, opts = {}) {
150150 continue
151151 }
152152
153 // Pass through HTML media tags (video, audio) without escaping
153 // Sanitized <video>/<audio> blocks: extract source URLs only and re-emit a
154 // clean tag. Never pass raw author HTML through — that allowed event-handler
155 // attributes (e.g. <video onloadstart=...>) to execute (stored XSS).
154156 if (/^\s*<(video|audio)[\s>]/.test(line)) {
155157 var tag = RegExp.$1
156158 var block = [line]
……@@ -158,7 +160,18 @@ export function renderMarkdown (src, opts = {}) {
158160 i++
159161 block.push(lines[i])
160162 }
161 html.push(block.join('\n'))
163 var raw = block.join('\n')
164 var srcs = []
165 var srcRe = /\bsrc\s*=\s*["']([^"']+)["']/gi
166 var sm
167 while ((sm = srcRe.exec(raw)) !== null) {
168 var su = safeUrl(sm[1])
169 if (su) srcs.push(su)
170 }
171 if (srcs.length) {
172 var sources = srcs.map(function (u) { return '<source src="' + esc(u) + '">' }).join('')
173 html.push('<' + tag + ' controls style="max-width:100%">' + sources + '</' + tag + '>')
174 }
162175 continue
163176 }
164177
……@@ -230,8 +243,11 @@ export function renderMarkdown (src, opts = {}) {
230243 return '<img alt="' + alt + '" src="' + src + '">'
231244 })
232245
233 // Links [text](url)
234 text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
246 // Links [text](url) — url MUST go through safeUrl (esc() does not catch the
247 // javascript:/data:/vbscript: scheme, so an unfiltered href is a stored XSS).
248 text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (_, label, url) {
249 return '<a href="' + safeUrl(url) + '">' + label + '</a>'
250 })
235251
236252 // Bold **text** or __text__
237253 text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
test/unit/markdown.test.js +38 -0
……@@ -0,0 +1,38 @@
1import test from 'brittle'
2import { renderMarkdown } from '../../lib/markdown.js'
3
4test('renderMarkdown - javascript: link scheme is neutralized', async (t) => {
5 const html = renderMarkdown('[click me](javascript:alert(document.cookie))')
6 t.absent(html.toLowerCase().includes('javascript:'), 'no javascript: scheme survives')
7 t.ok(html.includes('<a href="">click me</a>'), 'href emptied by safeUrl, label preserved')
8})
9
10test('renderMarkdown - data:/vbscript: link schemes are neutralized', async (t) => {
11 t.absent(renderMarkdown('[x](data:text/html,<script>1</script>)').toLowerCase().includes('data:'), 'data: blocked')
12 t.absent(renderMarkdown('[x](vbscript:msgbox)').toLowerCase().includes('vbscript:'), 'vbscript: blocked')
13})
14
15test('renderMarkdown - normal https link is preserved', async (t) => {
16 const html = renderMarkdown('see [example](https://example.com/p)')
17 t.ok(html.includes('<a href="https://example.com/p">example</a>'), 'safe http(s) link intact')
18})
19
20test('renderMarkdown - raw <video> event-handler attributes are stripped', async (t) => {
21 const html = renderMarkdown('<video onloadstart="alert(1)" onerror="alert(2)"><source src="/api/x.mp4"></video>')
22 t.absent(html.includes('onloadstart'), 'onloadstart attribute dropped')
23 t.absent(html.includes('onerror'), 'onerror attribute dropped')
24 t.absent(html.toLowerCase().includes('alert'), 'no inline JS survives')
25 t.ok(html.includes('<source src="/api/x.mp4">'), 'legitimate source URL preserved')
26 t.ok(html.includes('<video controls'), 'clean <video controls> emitted')
27})
28
29test('renderMarkdown - raw <video> with javascript: source is dropped', async (t) => {
30 const html = renderMarkdown('<video><source src="javascript:alert(1)"></video>')
31 t.absent(html.toLowerCase().includes('javascript:'), 'malicious source filtered by safeUrl')
32})
33
34test('renderMarkdown - markdown image still renders safely', async (t) => {
35 const html = renderMarkdown('![a picture](/api/notebooks/x/media/abc.png)')
36 t.ok(html.includes('<img'), 'image rendered')
37 t.ok(html.includes('src="/api/notebooks/x/media/abc.png"'), 'image src preserved')
38})