basecamp / basecamp/trix

Sanitizer and parser options resolve through the prototype chain

Open
#1,351 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
JavaScript
Stars
20k
Forks
1.1k
Avg merge
3d 12h
Merged PRs (30d)
13

Description

`HTMLSanitizer` and `HTMLParser` take their security-relevant settings as destructured options with a `{}` default:

```js
// src/trix/models/html_sanitizer.js
constructor(html, { allowedAttributes, forbiddenProtocols, forbiddenElements, purifyOptions } = {}) {
this.allowedAttributes = allowedAttributes || DEFAULT_ALLOWED_ATTRIBUTES
this.forbiddenProtocols = forbiddenProtocols || DEFAULT_FORBIDDEN_PROTOCOLS
this.forbiddenElements = forbiddenElements || DEFAULT_FORBIDDEN_ELEMENTS
this.purifyOptions = purifyOptions || {}
```

```js
// src/trix/models/html_parser.js
constructor(html, { referenceElement, purifyOptions } = {}) {
```

Destructuring is a property read, so each name resolves along the prototype chain. Our own call sites pass either no options object or a literal without these keys (`views/attachment_view.js`, `models/editor.js`, `models/composition.js`, `core/serialization.js`), so if `Object.prototype` carries any of these four names, that value wins over the defaults.

`purifyOptions` is the one with the most reach, because `sanitize()` does:

```js
const purifyConfig = Object.assign({}, config.dompurify, this.purifyOptions)
DOMPurify.setConfig(purifyConfig)
```

An inherited value is merged **over** `Trix.config.dompurify`, so an embedder's hardening is overridden rather than combined with. And since `setConfig()` is called without a matching `clearConfig()`, that configuration persists as the residual state later read by `DOMPurify.isValidAttribute` in `models/string_piece.js`, `views/attachment_view.js` and `controllers/toolbar_controller.js`.

### Scope, stated plainly

This is defence in depth, not a directly exploitable issue. Reaching it requires a prototype-pollution primitive from somewhere else on the page, and anything that can write to `Object.prototype` already has script execution, at which point the sanitizer is not what stands between an attacker and the user. We are not aware of a gadget in Trix that provides such a write, and we checked one candidate specifically: it replaces the prototype of an internal object only and leaves `Object.prototype` untouched.

We are filing it anyway, because reading security-relevant configuration through the prototype chain is a poor default even when nothing can currently reach it, and it makes Trix needlessly fragile for embedders who may have such a primitive in their own application.

### Suggested direction

Resolve these options without consulting the prototype chain, for example by taking the options object as a whole and checking `Object.hasOwn(options, key)` before use, or by normalising it through `Object.assign(Object.create(null), options)` at the top of each constructor. Worth applying the same treatment to `HTMLParser`'s `referenceElement` and `purifyOptions`, and considering `clearConfig()` after sanitizing so DOMPurify state does not persist between calls.

### Credit

Reported to our HackerOne program by **@the11gh0st**, who mapped each affected key to the specific defence it disables. Thanks for a clear, well-evidenced report.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start with the constructors in src/trix/models/html_sanitizer.js and src/trix/models/html_parser.js, then trace sanitize() and the DOMPurify.isValidAttribute reads in models/string_piece.js, views/attachment_view.js, and controllers/toolbar_controller.js. Verify how inherited options affect defaults and persistent configuration; done means prototype-chain values cannot override the intended settings and the affected sanitizer behavior remains correct.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript
Domain
security
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
62/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.