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:
parent
077d25a269
commit
c8c5531c0c
File diff suppressed because one or more lines are too long
|
|
@ -19,7 +19,7 @@
|
||||||
background: #13131f;
|
background: #13131f;
|
||||||
}
|
}
|
||||||
</style>
|
</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">
|
<link rel="stylesheet" crossorigin href="/assets/main-bQpH1es2.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,11 @@ const ALLOWED_VERSTAK_TYPES = new Set(['case', 'note', 'file', 'secret']);
|
||||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||||
if (node.tagName === 'A') {
|
if (node.tagName === 'A') {
|
||||||
const href = (node.getAttribute('href') || '').trim();
|
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)) {
|
if (!ALLOWED_SCHEMES.test(href)) {
|
||||||
node.removeAttribute('href');
|
node.removeAttribute('href');
|
||||||
node.setAttribute('data-blocked-href', 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)) {
|
if (parsed && ALLOWED_VERSTAK_TYPES.has(parsed.type)) {
|
||||||
const escapedHref = escapeAttr(trimmedHref);
|
const escapedHref = escapeAttr(trimmedHref);
|
||||||
const escapedText = escapeHtml(text);
|
const escapedText = escapeHtml(text);
|
||||||
// Use a hash-based href so DOMPurify doesn't strip it;
|
// Use about:blank as a safe href that DOMPurify won't strip.
|
||||||
// the actual navigation is handled by data-verstak-href + click handler.
|
// The actual navigation is handled by data-verstak-href + click handler.
|
||||||
const hashId = 'verstak-' + encodeURIComponent(parsed.type) + '-' + encodeURIComponent(parsed.id);
|
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 `<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>`;
|
|
||||||
}
|
}
|
||||||
// Unknown verstak type — render as blocked
|
// Unknown verstak type — render as blocked
|
||||||
const escapedText = escapeHtml(text);
|
const escapedText = escapeHtml(text);
|
||||||
|
|
|
||||||
|
|
@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttr(s) {
|
||||||
|
return s.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue