Sanitizer and parser options resolve through the prototype chain
- 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