Developers patched an XSS vulnerability by sanitizing content labeled as 'text/html'. Attackers bypassed this by labeling their malicious HTML as literally anything else (e.g., 'text/anything'). The renderer, ignoring the label, executed the code via innerHTML anyway. Fixed in version 2.1.4.
A logic flaw in Trix Editor's attachment handling allowed attackers to bypass XSS protections by simply mislabeling the content type of malicious payloads.
Rich text editors are the bane of web security. They exist in a paradoxical state where their entire job is to accept complex, formatted user input, but their security mandate is to reject anything that looks like code. Trix, the engine powering Basecamp, handles this by treating complex objects as "attachments"—embedded widgets defined by JSON blobs hidden inside data-trix-attachment attributes.
Previous versions of Trix had a bit of a problem with these attachments. If you could sneak malicious HTML into the attachment's content field, Trix would render it. The developers realized this and issued a fix. But as is tradition in the world of whack-a-mole security, the fix was... optimistic.
They implemented a check: if an attachment claimed to be text/html, it would be scrubbed by the sanitizer. The fatal assumption? That anything not labeled as HTML was inherently safe. Spoiler alert: browsers don't care what you label a string; if you shove it into innerHTML, it executes.
The vulnerability lives in the gap between the parser and the renderer. In the parser (html_parser.js), Trix implemented a gatekeeper. It looked at the incoming JSON data for an attachment and checked the contentType property.
// The "Security" Logic
if (data.contentType === "text/html" && data.content) {
data.content = HTMLSanitizer.sanitize(data.content).getHTML()
}Read that closely. If the attacker is polite enough to admit, "Yes, this is HTML," the sanitizer runs. But if the attacker lies—or simply uses a made-up MIME type like text/magical-unicorn—the if condition fails, and the code skips the sanitation block entirely.
This wouldn't be a problem if the renderer respected that content type. If text/magical-unicorn was rendered as a plain string or an image placeholder, we'd be fine. But AttachmentView didn't care about the type. It just checked if there was content, and if so, dumped it straight into the DOM.
Here is the vulnerable sink in src/trix/views/attachment_view.js. This code runs after the parser has decided whether or not to sanitize the data.
// src/trix/views/attachment_view.js (Vulnerable)
if (this.attachment.hasContent()) {
// It takes the content (unsanitized if contentType != text/html)
// and injects it directly into the DOM.
innerElement.innerHTML = this.attachment.getContent()
}This is a classic "Time of Check to Time of Use" (TOCTOU) logic error, albeit in a synchronous flow. The check (parser) allows non-HTML types to pass through raw. The use (renderer) treats all types as raw HTML. It is the security equivalent of a nightclub bouncer checking IDs only for people wearing "I am under 21" t-shirts.
Exploiting this requires no advanced memory corruption or heap spraying. You just need to know how to copy and paste. The attack vector relies on the user pasting a specially crafted HTML snippet into the editor. The Trix parser reads the data-trix-attachment attribute, parses the JSON, sees a "safe" content type, and hands the payload to the renderer.
Here is the PoC. We declare the contentType as text/anything (bypassing the check) and put our XSS payload in content.
<!-- The Trojan Horse -->
<div data-trix-attachment='{
"contentType": "text/anything",
"content": "<img src=x onerror=\"alert('Pwned by Trix')\">"
}'></div>When a victim pastes this div into a Trix editor (versions < 2.1.4), the onerror handler fires immediately. This allows for full arbitrary JavaScript execution in the context of the application—session hijacking, data exfiltration, or forcing the user to post embarrassing content on Basecamp.
The fix in version 2.1.4 (PR #1156) finally closes the loop. The developers stopped trying to be clever with conditional sanitation based on labels. instead, they moved the sanitation to the sink.
They introduced a new method, HTMLSanitizer.setHTML(element, html), and replaced the raw innerHTML assignment. Now, it doesn't matter what the contentType claims to be. Before any content touches the DOM, it goes through the sanitizer.
// src/trix/views/attachment_view.js (Patched)
if (this.attachment.hasContent()) {
// Force sanitation at the point of injection
HTMLSanitizer.setHTML(innerElement, this.attachment.getContent())
}This is the correct approach to output encoding: Sanitize at the boundary where the data leaves the application (or enters the DOM), not where it enters the system.
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N| Product | Affected Versions | Fixed Version |
|---|---|---|
Trix Basecamp | < 2.1.4 | 2.1.4 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-79 (Cross-site Scripting) |
| CVSS v3.1 | 6.5 (Medium) |
| Attack Vector | Network (User Interaction Required) |
| Impact | Confidentiality & Integrity (High) |
| Exploit Status | PoC Available |
| Patch Status | Fixed in v2.1.4 |
Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
Get the latest CVE analysis reports delivered to your inbox.