Frontmatter
' +
escapeHtml(capturedFrontmatter) + '
';
}
- return fmHtml + bodyHtml;
+ return fmHtml + safeBody;
}
function escapeHtml(s) {
diff --git a/tests/playground/voyage-playground.test.mjs b/tests/playground/voyage-playground.test.mjs
index 7506f0d..e815aad 100644
--- a/tests/playground/voyage-playground.test.mjs
+++ b/tests/playground/voyage-playground.test.mjs
@@ -483,6 +483,22 @@ test('voyage-playground.html renderArtifact strips comments before md.render (v4
assert.ok(stripIdx > 0 && stripIdx < renderIdx, 'stripUnsafeComments must run before md.render');
});
+// v4.3 Step 1 — SC24-security defense in depth: renderArtifact bodyHtml is
+// sanitized via DOMPurify before DOM injection (finding 1d3591d4).
+test('voyage-playground.html renderArtifact sanitizes bodyHtml via DOMPurify (v4.3 Step 1, finding 1d3591d4)', () => {
+ const text = readFileSync(HTML, 'utf-8');
+ // The literal DOMPurify.sanitize(bodyHtml expression must be present.
+ assert.match(text, /DOMPurify\.sanitize\(bodyHtml/, 'DOMPurify.sanitize(bodyHtml call required in renderArtifact');
+ // USE_PROFILES: { html: true } must appear nearby (within the renderArtifact body)
+ const bodyStart = text.indexOf('function renderArtifact');
+ assert.ok(bodyStart > 0, 'renderArtifact() must exist');
+ const bodyEnd = text.indexOf('\n }', bodyStart);
+ const body = text.slice(bodyStart, bodyEnd + 1);
+ assert.match(body, /USE_PROFILES:\s*\{\s*html:\s*true\s*\}/, 'USE_PROFILES html:true profile required inside renderArtifact');
+ // Return must reference safeBody, not raw bodyHtml
+ assert.match(body, /return\s+fmHtml\s*\+\s*safeBody/, 'renderArtifact return must use safeBody');
+});
+
// v4.3 Step 26 — path-traversal + symlink/dotfile filter.
test('voyage-playground.html declares isProjectPathSafe filter (v4.3 Step 26)', () => {
const text = readFileSync(HTML, 'utf-8');