fix: internal verstak:// links in markdown preview now clickable

Root cause: DOMPurify afterSanitizeAttributes hook was treating verstak://
links as blocked because hash-based href didnt match ALLOWED_SCHEMES regex.

Fix:
1. afterSanitizeAttributes hook now checks data-verstak-href first and
   returns early for internal links - they never get blocked
2. Changed href from hash-based to about:blank (safe value that
   DOMPurify wont strip, unlike javascript:void(0))
3. Click handler already uses data-verstak-href, not href

Added unit test: markdown.test.js (27 tests for renderer.link output)
This commit is contained in:
mirivlad 2026-06-15 12:15:42 +08:00
parent 077d25a269
commit c8c5531c0c
4 changed files with 147 additions and 22 deletions

View File

@ -19,7 +19,7 @@
background: #13131f;
}
</style>
<script type="module" crossorigin src="/assets/main-FdTYg97q.js"></script>
<script type="module" crossorigin src="/assets/main-BpXZraKT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-bQpH1es2.css">
</head>
<body>

View File

@ -34,6 +34,11 @@ const ALLOWED_VERSTAK_TYPES = new Set(['case', 'note', 'file', 'secret']);
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'A') {
const href = (node.getAttribute('href') || '').trim();
// Internal verstak links use hash-based href (e.g. #verstak-file-abc)
// and store the real URL in data-verstak-href. Don't block them.
if (node.hasAttribute('data-verstak-href')) {
return;
}
if (!ALLOWED_SCHEMES.test(href)) {
node.removeAttribute('href');
node.setAttribute('data-blocked-href', href);
@ -68,10 +73,9 @@ renderer.link = function ({ href, title, text }) {
if (parsed && ALLOWED_VERSTAK_TYPES.has(parsed.type)) {
const escapedHref = escapeAttr(trimmedHref);
const escapedText = escapeHtml(text);
// Use a hash-based href so DOMPurify doesn't strip it;
// the actual navigation is handled by data-verstak-href + click handler.
const hashId = 'verstak-' + encodeURIComponent(parsed.type) + '-' + encodeURIComponent(parsed.id);
return `<a href="#${hashId}" class="md-link md-link--internal" data-verstak-href="${escapedHref}" data-verstak-type="${escapeAttr(parsed.type)}" data-verstak-id="${escapeAttr(parsed.id)}">${escapedText}</a>`;
// Use about:blank as a safe href that DOMPurify won't strip.
// The actual navigation is handled by data-verstak-href + click handler.
return `<a href="about:blank" class="md-link md-link--internal" data-verstak-href="${escapedHref}" data-verstak-type="${escapeAttr(parsed.type)}" data-verstak-id="${escapeAttr(parsed.id)}">${escapedText}</a>`;
}
// Unknown verstak type — render as blocked
const escapedText = escapeHtml(text);

View File

@ -0,0 +1,121 @@
/**
* Unit tests for markdown renderer: internal verstak:// links
* Run: node frontend/src/lib/util/markdown.test.js
*/
// We need to test the renderMarkdown function which uses DOMPurify and marked.
// Since these are browser libraries, we'll test the renderer.link function directly
// and verify the expected HTML structure before DOMPurify.
// Inline the relevant parts from markdown.ts
const ALLOWED_SCHEMES = /^(https?|mailto|verstak):/i;
const ALLOWED_VERSTAK_TYPES = new Set(['case', 'note', 'file', 'secret']);
function parseVerstakUrl(href) {
const match = href.match(/^verstak:\/\/([a-zA-Z_]+)\/(.+)$/);
if (!match) return null;
return { type: match[1], id: match[2] };
}
function escapeHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeAttr(s) {
return s.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// Simulate renderer.link output (before DOMPurify)
function renderLink(href, text) {
const trimmedHref = (href || '').trim();
// Block dangerous schemes
if (!ALLOWED_SCHEMES.test(trimmedHref)) {
return `<span class="md-link--blocked" data-blocked-href="${escapeAttr(trimmedHref)}" role="link" aria-disabled="true">${escapeHtml(text)}</span>`;
}
// Internal verstak:// links
if (trimmedHref.startsWith('verstak://')) {
const parsed = parseVerstakUrl(trimmedHref);
if (parsed && ALLOWED_VERSTAK_TYPES.has(parsed.type)) {
const escapedHref = escapeAttr(trimmedHref);
const escapedText = escapeHtml(text);
return `<a href="about:blank" class="md-link md-link--internal" data-verstak-href="${escapedHref}" data-verstak-type="${escapeAttr(parsed.type)}" data-verstak-id="${escapeAttr(parsed.id)}">${escapedText}</a>`;
}
return `<span class="md-link--blocked" data-blocked-href="${escapeAttr(trimmedHref)}">${escapeHtml(text)}</span>`;
}
// External links
return `<a href="${escapeAttr(trimmedHref)}" class="md-link md-link--external" target="_blank" rel="noopener noreferrer">${escapeHtml(text)}</a>`;
}
// ─── Test runner ─────────────────────────────────────────────
let passed = 0, failed = 0;
function assert(label, actual, expected) {
if (actual === expected) {
passed++;
} else {
failed++;
console.error(`FAIL: ${label}`);
console.error(` actual: ${JSON.stringify(actual)}`);
console.error(` expected: ${JSON.stringify(expected)}`);
}
}
console.log('=== renderer.link output for verstak:// links ===');
// Test 1: verstak://file/...
const fileLink = renderLink('verstak://file/019e971db8967a53991d3ee22a64ccc7', 'screen_4.png');
assert('file link is <a>', fileLink.startsWith('<a '), true);
assert('file link has data-verstak-href', fileLink.includes('data-verstak-href="verstak://file/019e971db8967a53991d3ee22a64ccc7"'), true);
assert('file link has md-link--internal', fileLink.includes('md-link--internal'), true);
assert('file link has about:blank href', fileLink.includes('href="about:blank"'), true);
assert('file link is NOT blocked', fileLink.includes('md-link--blocked'), false);
assert('file link text', fileLink.includes('>screen_4.png</a>'), true);
assert('file link has data-verstak-type', fileLink.includes('data-verstak-type="file"'), true);
assert('file link has data-verstak-id', fileLink.includes('data-verstak-id="019e971db8967a53991d3ee22a64ccc7"'), true);
// Test 2: verstak://note/...
const noteLink = renderLink('verstak://note/abc123', 'My Note');
assert('note link is <a>', noteLink.startsWith('<a '), true);
assert('note link has data-verstak-href', noteLink.includes('data-verstak-href="verstak://note/abc123"'), true);
assert('note link has md-link--internal', noteLink.includes('md-link--internal'), true);
assert('note link is NOT blocked', noteLink.includes('md-link--blocked'), false);
// Test 3: verstak://case/...
const caseLink = renderLink('verstak://case/case_123', 'Project Alpha');
assert('case link is <a>', caseLink.startsWith('<a '), true);
assert('case link has data-verstak-type="case"', caseLink.includes('data-verstak-type="case"'), true);
assert('case link is NOT blocked', caseLink.includes('md-link--blocked'), false);
// Test 4: verstak://secret/...
const secretLink = renderLink('verstak://secret/sec_123', 'API Key');
assert('secret link is <a>', secretLink.startsWith('<a '), true);
assert('secret link has md-link--internal', secretLink.includes('md-link--internal'), true);
assert('secret link is NOT blocked', secretLink.includes('md-link--blocked'), false);
// Test 5: external https link
const extLink = renderLink('https://example.com', 'Example');
assert('external link is <a>', extLink.startsWith('<a '), true);
assert('external link has target=_blank', extLink.includes('target="_blank"'), true);
assert('external link has md-link--external', extLink.includes('md-link--external'), true);
assert('external link has href=https', extLink.includes('href="https://example.com"'), true);
// Test 6: blocked scheme (javascript:)
const blockedLink = renderLink('javascript:alert(1)', 'Click me');
assert('blocked link is <span>', blockedLink.startsWith('<span '), true);
assert('blocked link has md-link--blocked', blockedLink.includes('md-link--blocked'), true);
assert('blocked link has data-blocked-href', blockedLink.includes('data-blocked-href'), true);
// Test 7: unknown verstak type
const unknownLink = renderLink('verstak://unknown/xyz', 'Unknown');
assert('unknown verstak type is <span>', unknownLink.startsWith('<span '), true);
assert('unknown verstak type is blocked', unknownLink.includes('md-link--blocked'), true);
console.log();
console.log(`Results: ${passed} passed, ${failed} failed`);
if (failed > 0) {
process.exit(1);
} else {
console.log('ALL TESTS PASSED');
}