feat(voyage): vendor DOMPurify >=3.1.1 + sanitize annotation-content

This commit is contained in:
Kjell Tore Guttormsen 2026-05-10 18:01:30 +02:00
commit fc8c9eecdd
5 changed files with 105 additions and 6 deletions

View file

@ -1,9 +1,10 @@
{
"generated_at": "2026-05-09T13:16:03.483Z",
"generated_at": "2026-05-10T15:59:53.379Z",
"pins": {
"markdown-it": "14.1.0",
"markdown-it-front-matter": "0.2.4",
"highlight.js": "11.11.1"
"highlight.js": "11.11.1",
"dompurify": "3.2.6"
},
"highlight_languages": [
"yaml",
@ -16,6 +17,7 @@
"output_files": [
"markdown-it.min.js",
"markdown-it-front-matter.min.js",
"highlight.min.js"
"highlight.min.js",
"dompurify.min.js"
]
}

File diff suppressed because one or more lines are too long

View file

@ -1135,6 +1135,10 @@
<script src="lib/markdown-it.min.js"></script>
<script src="lib/markdown-it-front-matter.min.js"></script>
<script src="lib/highlight.min.js"></script>
<!-- v4.3 Step 24 — DOMPurify ≥ 3.1.1 (UMD bundle exposes window.DOMPurify).
Used by sanitizeAnnotation() to scrub annotation rich-text before DOM
insertion. Pinned via scripts/vendor-playground-libs.mjs. -->
<script src="lib/dompurify.min.js"></script>
<!-- Sample plan inlined for Step 8 first-run experience.
Same content as tests/fixtures/annotation/annotation-plan.md (truncated). -->
@ -1244,6 +1248,27 @@ playground first-run shows a complete round-trip-able artifact.
.replace(/'/g, '&#39;');
}
// v4.3 Step 24 — DOMPurify-backed annotation-content sanitizer.
// Strips <script>, inline styles, and event-handler attributes; keeps
// a small allowlist of inline-formatting tags so users can paste basic
// rich-text (bold/italic/code) without breaking export round-trip.
// Falls back to escapeHtml when DOMPurify is unavailable (file:// in a
// browser without the vendored bundle, or test environments).
function sanitizeAnnotation(html) {
var input = String(html == null ? '' : html);
if (typeof window !== 'undefined' && window.DOMPurify && typeof window.DOMPurify.sanitize === 'function') {
return window.DOMPurify.sanitize(input, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'code'],
ALLOWED_ATTR: [],
FORBID_TAGS: ['style', 'script'],
FORBID_ATTR: ['style', 'onerror', 'onload'],
});
}
// Defensive fallback — never inject untrusted HTML if DOMPurify
// failed to load.
return escapeHtml(input);
}
// Parse frontmatter yourself (cheap line-walk) so deriveStorageKey can
// see slug/type without wire-tapping the plugin.
function quickParseFrontmatter(text) {
@ -2306,7 +2331,11 @@ playground first-run shows a complete round-trip-able artifact.
(annot.line ? '<span class="critique-card__line">linje ' + annot.line + '</span>' : '') +
'</div>' +
(annot.snippet ? '<div class="critique-card__snippet">' + escapeHtmlInline(annot.snippet) + '</div>' : '') +
'<div class="critique-card__comment">' + escapeHtmlInline(annot.comment || '') + '</div>' +
// v4.3 Step 24 — comment is user-entered rich-text; route through
// sanitizeAnnotation (DOMPurify-backed) so basic inline-formatting
// tags survive while <script>, inline styles, and event-handler
// attributes are stripped before DOM insertion.
'<div class="critique-card__comment">' + sanitizeAnnotation(annot.comment || '') + '</div>' +
'<div class="critique-card__status' + (annot.exported ? ' critique-card__status--exported' : '') + '">' +
(annot.exported ? 'Eksportert' : 'Pending') +
'</div>';

View file

@ -9,9 +9,10 @@
// - markdown-it@14.1.0 (UMD bundle copied verbatim)
// - markdown-it-front-matter@0.2.4 (CommonJS module wrapped in IIFE)
// - highlight.js@11.11.1 (5-lang bundle assembled from CommonJS sources)
// - dompurify@3.2.6 (UMD bundle copied verbatim) — v4.3 Step 24
//
// Output: playground/lib/{markdown-it.min.js, markdown-it-front-matter.min.js,
// highlight.min.js}
// highlight.min.js, dompurify.min.js}
//
// All three output files are zero-network browser-loadable scripts that
// expose globals (`window.markdownit`, `window.markdownitFrontMatter`,
@ -32,6 +33,9 @@ const PINS = {
'markdown-it': '14.1.0',
'markdown-it-front-matter': '0.2.4',
'highlight.js': '11.11.1',
// v4.3 Step 24 — pinned ≥ 3.1.1 (PortSwigger HTML-comment mutation-XSS bypass
// was fixed in 3.1.x; 3.2.6 is the current stable line as of 2026-05-10).
'dompurify': '3.2.6',
};
const HL_LANGS = ['yaml', 'json', 'javascript', 'bash', 'markdown', 'diff'];
@ -77,7 +81,20 @@ function vendor() {
writeFileSync(join(OUT, 'highlight.min.js'), hlBundle);
log(`wrote ${join(OUT, 'highlight.min.js')} (${HL_LANGS.length} langs)`);
// 4. MANIFEST — record the vendored versions for audit
// 4. dompurify — copy UMD min bundle directly (v4.3 Step 24).
// Mirrors markdown-it-vendoring: npm pack → tar xzf → copy
// dist/purify.min.js → playground/lib/dompurify.min.js. The UMD bundle
// exposes `window.DOMPurify` for browser-loadable use.
log('packing dompurify@' + PINS['dompurify']);
execSync(`npm pack dompurify@${PINS['dompurify']} --silent`, { cwd: tmp });
execSync(`tar xzf dompurify-${PINS['dompurify']}.tgz`, { cwd: tmp });
copyFileSync(
join(tmp, 'package', 'dist', 'purify.min.js'),
join(OUT, 'dompurify.min.js'),
);
log(`wrote ${join(OUT, 'dompurify.min.js')}`);
// 5. MANIFEST — record the vendored versions for audit
const manifest = {
generated_at: new Date().toISOString(),
pins: PINS,
@ -86,6 +103,7 @@ function vendor() {
'markdown-it.min.js',
'markdown-it-front-matter.min.js',
'highlight.min.js',
'dompurify.min.js',
],
};
writeFileSync(

View file

@ -413,3 +413,50 @@ test('docs/screenshots/README.md documents mappestruktur + hooks (v4.3 Step 23)'
assert.match(text, /window\.__voyage\.scheduleRender/, 'scheduleRender hook documented');
assert.match(text, /window\.__voyage\.getProjectArtifacts/, 'getProjectArtifacts hook documented');
});
// v4.3 Step 24 — vendor DOMPurify + sanitize annotation-content
test('playground/lib/dompurify.min.js is vendored (v4.3 Step 24)', () => {
const path = join(PLAYGROUND, 'lib', 'dompurify.min.js');
assert.equal(existsSync(path), true, 'playground/lib/dompurify.min.js must exist (run scripts/vendor-playground-libs.mjs)');
const size = statSync(path).size;
// Sanity floor — DOMPurify min bundle is ~22 KB; reject empty/0-byte
assert.ok(size > 5000, 'dompurify.min.js too small (' + size + ' bytes) — vendor script may have failed');
});
test('playground/lib/VENDOR-MANIFEST.json pins dompurify >= 3.1.1 (v4.3 Step 24)', () => {
const path = join(PLAYGROUND, 'lib', 'VENDOR-MANIFEST.json');
const manifest = JSON.parse(readFileSync(path, 'utf-8'));
assert.ok(manifest.pins && manifest.pins.dompurify, 'manifest must pin dompurify');
// semver compare on major.minor: must be >= 3.1.1
const m = String(manifest.pins.dompurify).match(/^(\d+)\.(\d+)\.(\d+)/);
assert.ok(m, 'invalid dompurify pin format: ' + manifest.pins.dompurify);
const [, maj, min] = m;
assert.ok(Number(maj) > 3 || (Number(maj) === 3 && Number(min) >= 1), 'dompurify pin must be >= 3.1.1, got ' + manifest.pins.dompurify);
assert.ok(manifest.output_files.includes('dompurify.min.js'), 'manifest output_files must list dompurify.min.js');
});
test('voyage-playground.html loads dompurify.min.js (v4.3 Step 24)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /<script src="lib\/dompurify\.min\.js">/, 'lib/dompurify.min.js script tag required');
});
test('voyage-playground.html declares sanitizeAnnotation function with allowlist (v4.3 Step 24)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /function\s+sanitizeAnnotation\s*\(/, 'sanitizeAnnotation() function required');
// Must call DOMPurify.sanitize with an ALLOWED_TAGS allowlist
assert.match(text, /DOMPurify\.sanitize/, 'DOMPurify.sanitize call required');
assert.match(text, /ALLOWED_TAGS:\s*\[/, 'ALLOWED_TAGS allowlist required');
});
test('voyage-playground.html bundle stays under 460 KB HALT-gate (v4.3 Step 24)', () => {
// Sums voyage-playground.html + every playground/lib/*.js file. Per plan
// critic finding 18 — must be < 460000 bytes (40 KB margin under the
// 500 KB NFR).
const htmlSize = statSync(HTML).size;
const libDir = join(PLAYGROUND, 'lib');
const libFiles = readdirSync(libDir).filter((f) => f.endsWith('.js') || f.endsWith('.mjs'));
let libTotal = 0;
for (const f of libFiles) libTotal += statSync(join(libDir, f)).size;
const total = htmlSize + libTotal;
assert.ok(total < 460000, 'bundle size ' + total + ' bytes exceeds 460 KB HALT-gate (' + libFiles.length + ' lib files)');
});