Stored XSS in Trilium's Share Renderer via Iframe src Attribute Breakout
| GHSA | GHSA-7c7g-7c3p-v53w |
|---|---|
| CVE | Not 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. |
| Severity | High CVSS v3.1 base score 8.7 |
| CVSS vector | CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N |
| Weaknesses | CWE-79 — Stored Cross-Site Scripting (primary) • CWE-116 — Improper Encoding or Escaping of Output |
| Product | Trilium (TriliumNext) — open-source, self-hosted hierarchical note-taking / personal knowledge base, ~38k GitHub stars |
| Affected | All versions through v0.104.1 |
| Fixed in | v0.105.0, released 2026-08-19. Fix landed in commits b139677e3 and cbae79bfa (both 2026-08-03). |
| Reported by | Louis Sanchez — Voke Cyber |
| Disclosure | Reported 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
- AV:N — The share endpoint is reachable over the network with no special access to the host.
- AC:L — No timing conditions or non-default configuration are required; the payload fires on any normal share visit.
- PR:L — The attacker needs an account able to create and share a note, the baseline privilege for any Trilium user.
- UI:R — A victim has to open the share link. Since that is the entire purpose of publishing a share, this condition is trivially satisfied by normal use.
- S:C (Scope Changed) — The exploited component is the attacker's own note-editing privilege, but the impact lands on every other visitor's session in the browser, including an administrator's — a different security scope than the one the attacker holds.
- C:H / I:H — A victim's session grants full read and write access to whatever that session can reach, up to and including code execution via /api/script/exec for an admin victim.
- A:N — No direct availability impact from the exploit itself.
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:
- b139677e3 — "fix(share): keep a web view source URL inside the src attribute." Replaces the template-literal string with a real DOM element built via node-html-parser, setting the source through setAttribute() instead of string interpolation. setAttribute() escapes whatever value it is handed, so the source can only ever be read back as an attribute value — never as markup.
- cbae79bfa — "fix(share): only frame an absolute http(s) web view source." Adds a check that rejects any URL that is not an absolute http: or https: address before the frame is built at all — defense in depth beyond the escaping fix, since a web view's setup form was only ever meant to write an absolute URL in the first place.
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
- Upgrade to v0.105.0 or later. Both fix commits are included in that release.
- 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.
- 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
- 2026-07-02 — Reported via GitHub's private security advisory process, in scope under Trilium's SECURITY.md as XSS affecting other users. Added as a collaborator on the draft advisory and credited as reporter the same day.
- 2026-08-03 — Maintainer (eliandoran) accepted the report. Fixed the same day in commits b139677e3 and cbae79bfa. A CVE was requested from GitHub the same day.
- 2026-08-19 — Fix released as part of v0.105.0.
- Late August 2026 — Follow-up on advisory/CVE status. The maintainer confirmed the fix had already shipped and that the advisory would publish once the release reached 30 days old, and requested a CVE again.
- 2026-09-13 — GHSA-7c7g-7c3p-v53w published. No CVE assigned as of publication.
References
- GitHub Security Advisory: GHSA-7c7g-7c3p-v53w
- Fix commit (escape via DOM element): b139677e3
- Fix commit (restrict to absolute http/https): cbae79bfa
- Trilium repository: github.com/TriliumNext/Trilium
- Blog post (story and disclosure narrative): Stored XSS in Trilium's Share Renderer
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