Summary

Zen is vulnerable to two stored cross-site scripting (XSS) vectors that allow an authenticated user to execute arbitrary JavaScript under the application origin. Note titles and SQLite FTS5 search-highlight fields are both rendered into the DOM through Preact’s dangerouslySetInnerHTML without any HTML encoding. A payload stored via note create/update or .md import executes when the note editor mounts (title sink) or when the note matches a search query (search sink). The Markdown body path is not affected - renderMarkdown uses markdown-it with the default html: false, which escapes raw HTML; only the non-Markdown title and search-highlight code paths are vulnerable. Because the session_token cookie is script-readable, either vector is sufficient to hijack the victim’s session and take over the account.

Details

Both sinks share the same defect class: a stored, attacker-controlled string is passed verbatim as __html to dangerouslySetInnerHTML instead of being rendered as text or HTML-encoded.

Sink 1 - Note title (features/notes/NotesEditor.jsx:382)

<div className="notes-editor-title" contentEditable={isEditable} ref={titleRef} onBlur={handleTitleChange} dangerouslySetInnerHTML={{ __html: title }} />

title is the raw note title returned by the API. The backend stores and returns it as an opaque string with no HTML filtering:

The title is never passed through renderMarkdown or any encoder. Contrast with the body sink at features/notes/NotesEditor.jsx:318, which wraps content in renderMarkdown(...) - that path escapes raw HTML and is not vulnerable.

Sink 2 - Search highlights (features/search/SearchMenu.jsx:296-297)

<p className="title" dangerouslySetInnerHTML={{ __html: displayTitle }}></p>
<p className="subtitle" dangerouslySetInnerHTML={{ __html: displaySubtitle }}></p>

Where the display values are built unsanitized:

const displayTitle = item.highlightedTitle || title || ""

let displaySubtitle = subtitle
if (item.highlightedContent) {
  displaySubtitle = getHighlightedSnippet(item.highlightedContent)
} else if (item.content) {
  displaySubtitle = item.content
} else if (item.matchText) {
  displaySubtitle = item.matchText   // semantic-search fallback
}

highlightedTitle / highlightedContent come from SQLite FTS5 highlight() in features/notes/notes_model.go:679-680 (inside SearchNotes, line 673):

highlight(notes_search, 0, '<mark>', '</mark>') AS highlighted_title,
highlight(notes_search, 1, '<mark>', '</mark>') AS highlighted_content,

FTS5 highlight() wraps match spans in <mark>…</mark> but does not HTML-escape the surrounding column text. Any HTML already present in the stored note title/content survives into the API response and is injected verbatim into the DOM. The fallbacks (item.content, item.matchText, raw title) are likewise injected without encoding.

Why the Markdown body path is not affected. commons/utils/renderMarkdown.js initializes markdown-it with only linkify, breaks, and highlight; the html option is not set, so markdown-it uses its default html: false, which escapes raw HTML tags. Verified against the vendored library (assets/markdown-it.min.js): input <img src=x onerror=alert(1)> is emitted as <p>&lt;img src=x onerror=alert(1)&gt;</p>. The defect is therefore specific to the title and search-highlight code paths, which bypass renderMarkdown.