feat(voyage): vendor DOMPurify >=3.1.1 + sanitize annotation-content
This commit is contained in:
parent
e839ba2a7a
commit
fc8c9eecdd
5 changed files with 105 additions and 6 deletions
|
|
@ -1,9 +1,10 @@
|
||||||
{
|
{
|
||||||
"generated_at": "2026-05-09T13:16:03.483Z",
|
"generated_at": "2026-05-10T15:59:53.379Z",
|
||||||
"pins": {
|
"pins": {
|
||||||
"markdown-it": "14.1.0",
|
"markdown-it": "14.1.0",
|
||||||
"markdown-it-front-matter": "0.2.4",
|
"markdown-it-front-matter": "0.2.4",
|
||||||
"highlight.js": "11.11.1"
|
"highlight.js": "11.11.1",
|
||||||
|
"dompurify": "3.2.6"
|
||||||
},
|
},
|
||||||
"highlight_languages": [
|
"highlight_languages": [
|
||||||
"yaml",
|
"yaml",
|
||||||
|
|
@ -16,6 +17,7 @@
|
||||||
"output_files": [
|
"output_files": [
|
||||||
"markdown-it.min.js",
|
"markdown-it.min.js",
|
||||||
"markdown-it-front-matter.min.js",
|
"markdown-it-front-matter.min.js",
|
||||||
"highlight.min.js"
|
"highlight.min.js",
|
||||||
|
"dompurify.min.js"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3
plugins/voyage/playground/lib/dompurify.min.js
vendored
Normal file
3
plugins/voyage/playground/lib/dompurify.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -1135,6 +1135,10 @@
|
||||||
<script src="lib/markdown-it.min.js"></script>
|
<script src="lib/markdown-it.min.js"></script>
|
||||||
<script src="lib/markdown-it-front-matter.min.js"></script>
|
<script src="lib/markdown-it-front-matter.min.js"></script>
|
||||||
<script src="lib/highlight.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.
|
<!-- Sample plan inlined for Step 8 first-run experience.
|
||||||
Same content as tests/fixtures/annotation/annotation-plan.md (truncated). -->
|
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, ''');
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Parse frontmatter yourself (cheap line-walk) so deriveStorageKey can
|
||||||
// see slug/type without wire-tapping the plugin.
|
// see slug/type without wire-tapping the plugin.
|
||||||
function quickParseFrontmatter(text) {
|
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>' : '') +
|
(annot.line ? '<span class="critique-card__line">linje ' + annot.line + '</span>' : '') +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
(annot.snippet ? '<div class="critique-card__snippet">' + escapeHtmlInline(annot.snippet) + '</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' : '') + '">' +
|
'<div class="critique-card__status' + (annot.exported ? ' critique-card__status--exported' : '') + '">' +
|
||||||
(annot.exported ? 'Eksportert' : 'Pending') +
|
(annot.exported ? 'Eksportert' : 'Pending') +
|
||||||
'</div>';
|
'</div>';
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,10 @@
|
||||||
// - markdown-it@14.1.0 (UMD bundle copied verbatim)
|
// - markdown-it@14.1.0 (UMD bundle copied verbatim)
|
||||||
// - markdown-it-front-matter@0.2.4 (CommonJS module wrapped in IIFE)
|
// - markdown-it-front-matter@0.2.4 (CommonJS module wrapped in IIFE)
|
||||||
// - highlight.js@11.11.1 (5-lang bundle assembled from CommonJS sources)
|
// - 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,
|
// 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
|
// All three output files are zero-network browser-loadable scripts that
|
||||||
// expose globals (`window.markdownit`, `window.markdownitFrontMatter`,
|
// expose globals (`window.markdownit`, `window.markdownitFrontMatter`,
|
||||||
|
|
@ -32,6 +33,9 @@ const PINS = {
|
||||||
'markdown-it': '14.1.0',
|
'markdown-it': '14.1.0',
|
||||||
'markdown-it-front-matter': '0.2.4',
|
'markdown-it-front-matter': '0.2.4',
|
||||||
'highlight.js': '11.11.1',
|
'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'];
|
const HL_LANGS = ['yaml', 'json', 'javascript', 'bash', 'markdown', 'diff'];
|
||||||
|
|
@ -77,7 +81,20 @@ function vendor() {
|
||||||
writeFileSync(join(OUT, 'highlight.min.js'), hlBundle);
|
writeFileSync(join(OUT, 'highlight.min.js'), hlBundle);
|
||||||
log(`wrote ${join(OUT, 'highlight.min.js')} (${HL_LANGS.length} langs)`);
|
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 = {
|
const manifest = {
|
||||||
generated_at: new Date().toISOString(),
|
generated_at: new Date().toISOString(),
|
||||||
pins: PINS,
|
pins: PINS,
|
||||||
|
|
@ -86,6 +103,7 @@ function vendor() {
|
||||||
'markdown-it.min.js',
|
'markdown-it.min.js',
|
||||||
'markdown-it-front-matter.min.js',
|
'markdown-it-front-matter.min.js',
|
||||||
'highlight.min.js',
|
'highlight.min.js',
|
||||||
|
'dompurify.min.js',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
|
|
|
||||||
|
|
@ -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\.scheduleRender/, 'scheduleRender hook documented');
|
||||||
assert.match(text, /window\.__voyage\.getProjectArtifacts/, 'getProjectArtifacts 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)');
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue