basecamp/trix · error · TypeError

root node is forbidden and cannot be sanitized in-place

Error message

root node is forbidden and cannot be sanitized in-place

What it means

In in-place sanitization (sanitizing a live DOM root), DOMPurify pre-flights the root node itself: if the root's tagName is not in ALLOWED_TAGS (or is in FORBID_TAGS), the entire root is forbidden. Because the caller requested in-place sanitization, DOMPurify cannot substitute a safe replacement, so it first neutralizes the root (_neutralizeRoot: scrubbing handlers/children fail-closed) and then throws this TypeError instead of returning the unsafe tree.

Source

Thrown at action_text-trix/app/assets/javascripts/trix.js:4019

      if (inPlace) {
        /* Declarative-partial-updates / streaming pre-pass: sever every patch
           linkage across the live tree BEFORE the walk, so no patch can fire
           mid-walk and inject into an already-processed region. Runs first, so
           it also covers the forbidden/clobbered roots that throw below. */
        _neutralizePatchLinkage(dirty);
        /* Do some early pre-sanitization to avoid unsafe root nodes.
           Read nodeName through the cached prototype getter — a clobbering
           child named "nodeName" on the form root would otherwise shadow
           the property and let this check skip the root-allowlist
           validation entirely. */
        const nn = getNodeName ? getNodeName(dirty) : dirty.nodeName;
        if (typeof nn === 'string') {
          const tagName = transformCaseFunc(nn);
          if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) {
            /* Fail closed on a live root: neutralize handlers/children before
               throwing, exactly as the mid-walk abort path does. */
            _neutralizeRoot(dirty);
            throw typeErrorCreate('root node is forbidden and cannot be sanitized in-place');
          }
        }
        /* Pre-flight the root through _isClobbered. The iterator-driven
           removal path can not detach a parent-less root: _forceRemove
           falls through to Element.prototype.remove(), which per spec
           is a no-op on a node with no parent. A clobbered root would
           then survive the main loop with its attributes uninspected,
           because _sanitizeAttributes early-returns on _isClobbered. The
           result would be an attacker-controlled form, complete with any
           event-handler attributes the caller passed in, handed back to
           the application unsanitized. Refuse to sanitize such a root
           the same way we refuse a forbidden tag. GHSA-r47g-fvhr-h676. */
        if (_isClobbered(dirty)) {
          /* Fail closed on a live clobbered root before throwing.
             _neutralizeRoot's reads are clobber-safe (cached getters); the
             form's non-clobbered descendants, e.g. an armed <img>, are scrubbed. */
          _neutralizeRoot(dirty);
          throw typeErrorCreate('root node is clobbered and cannot be sanitized in-place');

View on GitHub (pinned to 4700401311)

Solutions

  1. Allow the root tag: add it via ALLOWED_TAGS/ADD_TAGS (or adjust USE_PROFILES) if the root element is genuinely safe to keep.
  2. Alternatively sanitize an inner container that has an allowed tag, or serialize to HTML and sanitize the string instead of in-place mode.
  3. If the root should never be allowed, replace it before sanitizing (e.g. wrap children in a <div> root).
  4. Catch the TypeError and fail closed: discard the node or rebuild it from sanitized outerHTML of its children.

Example fix

// before
DOMPurify.sanitize(customEl, { IN_PLACE: true, ADD_TAGS: [] }); // throws: root forbidden
// after
DOMPurify.sanitize(customEl, { IN_PLACE: true, ADD_TAGS: ['my-widget'] });
// or sanitize an allowed wrapper instead
DOMPurify.sanitize(wrapperDiv, { IN_PLACE: true });
Defensive patterns

Strategy: validation

Validate before calling

const tag = root.tagName.toLowerCase();
const cfg = { ALLOWED_TAGS: DOMPurify.defaults.ALLOWED_TAGS /* + your additions */ };
if (!cfg.ALLOWED_TAGS.includes(tag)) {
  // swap root for an allowed wrapper or add the tag via ADD_TAGS before calling
}

Type guard

const isAllowedRoot = (n, allowedTags) => n instanceof Element && allowedTags.includes(n.tagName.toLowerCase());

Try / catch

try {
  DOMPurify.sanitize(root, { IN_PLACE: true, ADD_TAGS: ['my-widget'] });
} catch (e) {
  if (String(e.message).includes('root node is forbidden')) {
    const safe = DOMPurify.sanitize(root.outerHTML); // rebuild from string
  } else { throw e; }
}

Prevention

When it happens

Trigger: DOMPurify.sanitize(rootElement, { IN_PLACE: true, ... }) where the root element's tag is disallowed by the current tag allowlist/forbidlist — e.g. sanitizing a <script>, <iframe> or custom element as the root while it is not in ALLOWED_TAGS/ADD_TAGS (or is explicitly FORBID_TAGS).

Common situations: Sanitizing a whole <form> or <body>-adjacent element whose tag was later FORBID_TAGS'd; tightening the default allowlist (e.g. USE_PROFILES) so the root element itself is no longer allowed; sanitizing custom elements before adding them via ADD_TAGS.

Understand the failure class

Related errors


AI-assisted analysis of basecamp/trix@4700401311 (2026-09-02). Data as JSON: /api/errors/9d553b423a044830. Report an issue: GitHub.