markdown: links open nothing they can reach back from

Every link with a target now gets rel="noopener noreferrer", and only
_blank is kept as a target. A named target such as target="foo" left
the page it opened a handle on this one — browsers imply noopener for
_blank alone — which it could use to send the tab to a lookalike login.

The share page turned links into the private workspace into plain text
with a regex over the sanitised HTML. A pattern over markup can end
inside an attribute, and on an engine that does not escape < and > in
attribute values a crafted link title became markup. The rewrite now
happens in renderMarkdown, on the parsed page.
This commit is contained in:
nvrl committed 2026-10-20 11:45:11 +02:00
1 parent a1bc0a9995
commit 7f15206ffb
2 files changed
+36 -18

No files matched your search

+2 -12
View File
@@ -18,18 +18,8 @@
let scroller = $state<HTMLElement>()
let article = $state<HTMLElement>()
let position = $state('All')
/*
* A link to another document is an address inside the private workspace,
* and leads a reader to its login page. Those links are kept as their text.
*/
const html = $derived(
doc
? renderMarkdown(doc.body, { token, key: shareKey }).replace(
/<a href="\/n\/[^"]*"[^>]*>(.*?)<\/a>/g,
'<span class="private-link" title="a page of the private workspace">$1</span>',
)
: '',
)
// Links into the private workspace come back as their text; see renderMarkdown.
const html = $derived(doc ? renderMarkdown(doc.body, { token, key: shareKey }) : '')
const title = $derived(doc?.name.trim() ?? '')
const words = $derived(doc ? doc.body.split(/\s+/).filter(Boolean).length : 0)
// The outline gets a window of its own only when there is something to outline.
+34 -6
View File
@@ -57,6 +57,23 @@ function tex(source: string, display: boolean): string {
return katex.renderToString(source, { displayMode: display, throwOnError: false })
}
/*
* A link that opens another window leaves it no handle on this one. Only
* `_blank` is kept as a target: a named one reuses whatever window has that
* name, and browsers give `noopener` to `_blank` alone.
*/
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName !== 'A' || !node.hasAttribute('target')) return
if (node.getAttribute('target') === '_blank') node.setAttribute('rel', 'noopener noreferrer')
else node.removeAttribute('target')
})
const SANITIZE = {
// `semantics` and `annotation` carry KaTeX's MathML, for screen readers.
ADD_TAGS: ['video', 'audio', 'source', 'semantics', 'annotation'],
ADD_ATTR: ['controls', 'target', 'loading', 'playsinline', 'preload', 'encoding'],
}
function escapeHtml(text: string): string {
return text.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;')
}
@@ -65,7 +82,9 @@ function escapeHtml(text: string): string {
* Render entry markdown to sanitised HTML. When share credentials are given,
* media URLs are rewritten to the public route — token and key both, since that
* route demands the same pair the entry itself did — so the page works for a
* reader who has no session.
* reader who has no session, and a link to another document, an address inside
* the private workspace that would lead the reader to its login page, is kept
* as its text.
*/
export function renderMarkdown(source: string, share?: { token: string; key: string }): string {
const md = share
@@ -76,12 +95,21 @@ export function renderMarkdown(source: string, share?: { token: string; key: str
: source
const html = marked.parse(md, { async: false }) as string
if (!share) return DOMPurify.sanitize(html, SANITIZE)
return DOMPurify.sanitize(html, {
// `semantics` and `annotation` carry KaTeX's MathML, for screen readers.
ADD_TAGS: ['video', 'audio', 'source', 'semantics', 'annotation'],
ADD_ATTR: ['controls', 'target', 'loading', 'playsinline', 'preload', 'encoding'],
})
// On the parsed page, not on its text: a pattern over HTML can end inside
// an attribute and turn what was quoted there into markup.
const page = DOMPurify.sanitize(html, { ...SANITIZE, RETURN_DOM_FRAGMENT: true })
for (const link of page.querySelectorAll('a[href^="/n/"]')) {
const text = document.createElement('span')
text.className = 'private-link'
text.title = 'a page of the private workspace'
text.append(...link.childNodes)
link.replaceWith(text)
}
const holder = document.createElement('div')
holder.append(page)
return holder.innerHTML
}
/** The markdown snippet that embeds a freshly uploaded file. */