Trilium's Share Renderer Had a Quoting Bug, and It Was Enough for Remote Code Execution
Most stored XSS write-ups involve a missing sanitizer — some field nobody thought to filter. This one is more specific, and more instructive, because Trilium's share renderer did sanitize the value in question. The sanitizer just wasn't built to do the job it was being asked to do, and the gap between "sanitizes URLs" and "escapes HTML attributes" is exactly where this bug lived.
The short version
A Trilium "Web View" note embeds an external page in an iframe, with the source URL held in a note label. The share renderer inserted that label's value directly into the iframe's src="..." attribute after running it through a URL sanitizer that strips dangerous schemes like javascript: — but does not HTML-escape its output. For a relative path or a non-http(s) URL, the sanitizer hands the value back untouched, quotes and all. A note owner could set the label to a value ending in a quote and an onload attribute, publish the note as a public share, and run JavaScript in the browser of anyone who opened it, including another admin. Fixed same-day by the maintainer, shipped in v0.105.0.
What Trilium is
Trilium (developed under the TriliumNext org after the original project's maintenance transitioned) is an open-source, self-hosted note-taking application built around a hierarchical tree of notes rather than a flat list — closer to a personal wiki than a simple notes app. It has around 38,000 GitHub stars and a genuinely large self-hosted user base. One of its features is Share: publish a note, or a subtree of notes, as a public (or credentialed) web page that anyone with the link can view without a Trilium account.
A note can also be typed as a Web View, which renders as an iframe pointed at an external page. The source for that iframe is stored in a #webViewSrc label on the note — ordinary Trilium metadata, editable from the same attribute panel as any other label.
What I found
When the share server renders a Web View note, it builds the iframe tag like this:
result.content = `<iframe class="webview" src="${sanitize.sanitizeUrl(url)}" sandbox="..."></iframe>`;
sanitizeUrl comes from the @braintree/sanitize-url library. Its job is to stop dangerous URL schemes: give it javascript:alert(1) and it comes back as about:blank. But for anything it does not consider dangerous — an absolute https:// URL, sure, but also a relative path, a mailto: address, a wss:// URL — the library's only real transformation is to lowercase and validate the scheme. Everything else in the string, including a literal double quote, passes straight through.
That distinction matters enormously once the value lands inside an HTML attribute. A URL sanitizer's contract is "this won't navigate somewhere dangerous." It says nothing about "this won't contain a character that ends the attribute it's sitting in." Those are two different jobs, and only one of them was being done.
One quote, and the frame grows an extra attribute
Set the label to this:
#webViewSrc = ./a" onload="alert(document.domain)" data-x="
The sanitizer sees a relative path, decides it's fine, and returns it byte for byte. The server then emits:
<iframe class="webview" src="./a" onload="alert(document.domain)" data-x="" sandbox="..."></iframe>
To a browser, that is not one attribute with a strange value. It's five ordinary attributes: class, src, onload, data-x, and sandbox. The quote in the label did exactly what a quote does in HTML — it ended the attribute it was inside — and everything after it became new markup the page author never wrote.
From a note label to the server's origin
The attack path is short. Any user able to create and share a note can:
- Create a Web View note and set #webViewSrc to a quote-carrying payload.
- Share it publicly, or under an existing shared subtree.
- Wait for someone to open the link.
Whoever opens it — an anonymous visitor, a colleague sent the URL, an admin auditing what got published — loads the iframe, and the onload handler runs in their browser, on the Trilium share origin. That origin is the same one serving /api/*, /login, and /api/script/exec. A script running there can read the visitor's session cookie, or skip that step entirely and just issue authenticated requests directly with fetch(url, {credentials: 'include'}).
/api/script/exec is the detail that turns this from "an XSS" into "an RCE." It runs a Trilium script note as server-side Node.js. If the visitor who loads a malicious share happens to be an administrator — a very ordinary thing for an admin to do, since checking what got published is routine maintenance — the payload can call that endpoint with the admin's own session and execute arbitrary code on the host. One quote character in a label, three steps, full server compromise.
Why the fix wasn't just "add escaping"
The maintainer's fix, shipped the same day the report was accepted, does two things worth calling out separately, because they solve different parts of the same problem:
- Stop building HTML with string interpolation. The rewritten code constructs the iframe as an actual DOM element and calls setAttribute("src", ...) instead of splicing a string into a template literal. setAttribute() escapes whatever it's given as a matter of course — there's no path left for a value to be interpreted as anything other than an attribute value, because the browser-model-level API doesn't offer one.
- Stop trusting non-http(s) sources at all. A second commit added a check that only renders the frame when the source is an absolute http: or https: URL — which is the only kind of value the Web View setup form was ever meant to produce in the first place. Anything else now fails to render and logs an error, closing off the whole class of "value reached the label through some route other than the form" concerns, not just this one exploit shape.
The first fix closes this specific bug. The second fix means the next person who finds a clever way to get an unexpected value into that label doesn't get an XSS out of it either — the renderer just won't build a frame for anything that isn't a proper web address.
What actually happened with the vendor
This is one of the more straightforward disclosures I've filed. Reported July 2 through GitHub's private security advisory process. The maintainer, eliandoran, accepted the report and fixed it the same day, August 3 — both commits landed within twenty minutes of each other. A CVE was requested from GitHub that same day.
What took time after that was publication, not remediation: GitHub's advisory workflow holds a fix privately until a public write-up exists, and Trilium's own practice is to let a release age roughly a month before making the underlying issue public, so self-hosted operators have a real window to update before the details are out. v0.105.0 shipped August 19; the advisory published a few weeks later, on September 13. As of that publication, no CVE identifier exists yet, despite two separate requests.
What self-hosted Trilium operators should do
- Upgrade to v0.105.0 or later.
- Check any shared Web View notes for a #webViewSrc value you don't recognize, before you upgrade.
- After upgrading, fix any Web View note pointed at a relative path or a non-http(s) URL — those will stop rendering under the hardened check.
The general lesson
A sanitizer named for what it blocks tells you nothing about what it's safe to do with the output. "Strips dangerous schemes" and "safe to place inside an HTML attribute" sound like they should be the same guarantee. They aren't, and the library here never claimed otherwise — its own docs describe scheme normalization, not escaping. The bug wasn't a missing check. It was one function's output being handed to a context that needed a different one.
For the full technical detail — the CVSS breakdown, the exact fix commits, and remediation steps — see the full advisory.
The bugs that matter take a human
A URL sanitizer that isn't an HTML escaper doesn't show up in a dependency scan. It shows up when someone reads the rendering code and asks what the sanitizer actually promises versus what the caller assumes it does. That is the same work we do on web application penetration tests and API assessments for clients across the Charlotte, NC area and nationwide.
Get a Quote Book a 15-min call