Voke Cyber Security Advisory

Stored XSS in Trilium's Share Renderer via Iframe src Attribute Breakout

Louis Sanchez Published September 13, 2026 9 min read
GHSAGHSA-7c7g-7c3p-v53w
CVENot yet assigned. GitHub requested one on 2026-08-03 and again in late August 2026; as of this advisory's publication (2026-09-13) no record exists on cve.org.
SeverityHigh  CVSS v3.1 base score 8.7
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N
WeaknessesCWE-79 — Stored Cross-Site Scripting (primary) • CWE-116 — Improper Encoding or Escaping of Output
ProductTrilium (TriliumNext) — open-source, self-hosted hierarchical note-taking / personal knowledge base, ~38k GitHub stars
AffectedAll versions through v0.104.1
Fixed inv0.105.0, released 2026-08-19. Fix landed in commits b139677e3 and cbae79bfa (both 2026-08-03).
Reported byLouis Sanchez — Voke Cyber
DisclosureReported 2026-07-02 via GitHub private security advisory. Fixed and CVE requested 2026-08-03. Advisory published 2026-09-13.

Summary

Trilium's "Web View" note type embeds an external page in an iframe, with the source URL stored in a #webViewSrc label. The share renderer builds that iframe by dropping the label's value straight into the src="..." attribute after running it through a URL sanitizer that blocks dangerous schemes but does not HTML-escape its output. For a relative path or a non-http(s) scheme, the sanitizer returns the value verbatim — quotes included. A note owner who sets the label to a value containing a double quote closes the src attribute early and adds arbitrary attributes of their own, including onload="...". Publish that note under a public share, and anyone who opens the share link — including another authenticated Trilium user or an admin — runs the attacker's JavaScript in the Trilium origin: the same origin as /api/*, /login, and /api/script/exec. The maintainer fixed it the same day the report was accepted, and it shipped in v0.105.0.

Root cause

A Trilium note of type Web View renders as an iframe pointed at a URL the note owner sets on the #webViewSrc label. When a note carrying this label is shared — publicly, or behind share credentials — the server's share renderer builds the iframe markup for the page every visitor's browser receives:

// apps/server/src/share/content_renderer.ts — before the fix
function renderWebView(note, result) {
    const url = note.getLabelValue("webViewSrc");
    if (!url) return;

    result.content = `<iframe class="webview" src="${sanitize.sanitizeUrl(url)}" sandbox="allow-same-origin allow-scripts allow-popups"></iframe>`;
}

sanitize.sanitizeUrl wraps the @braintree/sanitize-url library. For an http: or https: URL it parses the value with new URL(), which URL-encodes a literal quote as %22 — safe. For everything else — mailto:, ftp:, wss:, a relative path starting with . or / — the library returns the input string unchanged, including any embedded " characters. The sanitizer's job was to strip dangerous schemes like javascript:, which it does. It was never an HTML escaper, and nothing downstream treated it as one.

The result is dropped into the page template with <%- content %> — an EJS tag that emits its value unescaped by design, since content is meant to already be safe HTML by the time it gets there.

Proof of concept

Setting the label to a relative path with a trailing quote and attribute reproduces the break-out with no HTTP URL involved at all:

#webViewSrc = ./a" onload="alert(document.domain)" data-x="

The sanitizer returns that value verbatim, and the server emits:

<iframe class="webview" src="./a" onload="alert(document.domain)" data-x="" sandbox="allow-same-origin allow-scripts allow-popups"></iframe>

A browser parses that as five separate attributes on one element — class, src, onload, data-x, and sandbox — and runs the onload handler the moment the frame loads. A mailto: value works identically, since it is a non-http(s) scheme the sanitizer also returns untouched.

Attack scenario

Prerequisite: an authenticated Trilium account able to create a note and share it. On the default single-user instance, that is simply the owner; on any deployment where a note owner shares to the internet, the "attacker" is anyone with edit access to the shared subtree.

Step 1 — Plant. Create a note, set its type to Web View, and set the #webViewSrc label to a quote-carrying payload. In the editor the note looks like a normal, if oddly configured, web view — there is no visual warning that the label is malformed.

Step 2 — Publish. Add #shareRoot to a parent note, or place the note under the existing _share subtree, without #shareCredentials so the share is public.

Step 3 — Wait for a visit. Anyone who opens the share URL — a public visitor, a colleague sent the link, or an administrator checking what got published — loads the iframe and fires the payload in their own browser, on the Trilium origin.

Step 4 — Act on the session. From there the attacker's script can read document.cookie and exfiltrate it, or simply issue authenticated requests directly: fetch('/api/...', {credentials: 'include'}) reaches every endpoint the visitor's session can reach. On a server where an administrator opens the share, that includes POST /api/script/exec — which runs arbitrary Node.js on the server. One shared note turns into remote code execution the moment the right visitor loads it.

CVSS vector rationale

CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N

This vector computes to 8.7 (High), matching the score GitHub's own advisory calculator publishes for the same vector. The report that opened this case also proposed 9.0 (treating the RCE path as automatic) and a conservative 7.6 (dropping the scope change); we hold to what the published vector actually computes to.

The fix

The maintainer fixed this the same day the report was accepted, in two commits, both released in v0.105.0:

Operational note for self-hosted instances

The second commit is a behavior change, not just a hardening: after upgrading, a Web View note whose #webViewSrc is a relative path or a non-http(s) scheme (set by hand, by import, or via ETAPI) will stop rendering and log a server-side error instead. If you rely on a Web View note pointed at anything other than an absolute http:// or https:// address, update it after upgrading.

Remediation for self-hosted operators

  1. Upgrade to v0.105.0 or later. Both fix commits are included in that release.
  2. Audit existing Web View notes, especially shared ones. Before upgrading, review any shared note of type Web View for a #webViewSrc value you did not set yourself or that contains unexpected characters.
  3. Treat any public share as attacker-reachable by any contributor. Anyone who can create and share a note in your instance could exploit this on an unpatched version — reduce sharing/edit access to what is actually needed.

Disclosure timeline

References

Found and reported by Louis Sanchez, Founder & Principal Security Consultant at Voke Cyber (OSCP, OSWA, CISSP, CCSK). Prior advisories: Leantime JSON-RPC Account Takeover, and CVE-2026-16772 (Akaunting).

We find the bugs tools miss

An attribute breakout in a rendered iframe does not show up in a dependency scan. It shows up when someone reads the code that builds HTML from user-controlled values and asks what happens when that value carries a quote. That is how we approach web application testing for clients across the Charlotte, NC area and nationwide.

Get a Quote Book a 15-min call