{"record":{"id":"1180e690abbd48ca","repo":"basecamp/trix","slug":"a-node-selected-for-removal-could-not-be-detached","errorCode":null,"errorMessage":"a node selected for removal could not be detached from its tree and cannot be safely returned; refusing to sanitize in place","messagePattern":"a node selected for removal could not be detached from its tree and cannot be safely returned; refusing to sanitize in place","errorType":"exception","errorClass":"TypeError","httpStatus":null,"severity":"error","filePath":"action_text-trix/app/assets/javascripts/trix.js","lineNumber":2853,"sourceCode":"           .remove() is itself a spec no-op on a parentless node, so a recorded\n           \"removal\" would otherwise hand the caller back an intact,\n           payload-bearing node (e.g. a detached IN_PLACE root the mXSS canary or\n           the style-with-element-child rule decided to kill). Fail closed by\n           throwing — exactly as a clobbered root does at the IN_PLACE entry —\n           rather than trying to \"neutralize\" the node via its own methods.\n           Neutralizing would mean calling getAttributeNames()/removeAttribute()\n           on the node, both of which a <form> root can clobber via a named child\n           (and _isClobbered does not even probe getAttributeNames), so the\n           neutralize step could itself be silently defeated, leaving the payload\n           intact. A throw touches only the cached, clobber-safe remove() and\n           getParentNode(). Generalizes GHSA-r47g-fvhr-h676 (clobbered-form root)\n           to every root-kill reason. REPORT-3.\n                  This lives inside the catch, so it never fires for a normally-removed\n           in-tree node: those have a parent, removeChild() succeeds, and the\n           catch is not entered. Only a kept (parentless) root reaches here. */\n        remove(node);\n        if (!getParentNode(node)) {\n          throw typeErrorCreate('a node selected for removal could not be detached from its tree ' + 'and cannot be safely returned; refusing to sanitize in place');\n        }\n      }\n    };\n    /**\n     * _neutralizeRoot\n     *\n     * Fail-closed teardown of an in-place root after the sanitize walk aborts\n     * (campaign-3 F2). An internal throw mid-walk — e.g. a page-registered\n     * custom element's reaction detaches a node so `_forceRemove`'s deliberate\n     * parentless guard throws, or any other re-entrant engine mutation — would\n     * otherwise leave the caller's *live* tree half-sanitized, with everything\n     * after the abort point still carrying its handlers. There is no safe way\n     * to resume the walk (the tree mutated under us), so we strip the root bare:\n     * remove every child and every attribute, then let the caller's catch see\n     * the original error. Clobber-safe (cached `remove`/`childNodes`/`attributes`\n     * getters; the root was already clobber-pre-flighted at the IN_PLACE entry).\n     *\n     * @param root the in-place root to empty","sourceCodeStart":2835,"sourceCodeEnd":2871,"githubUrl":"https://github.com/basecamp/trix/blob/470040131122bd44e269b4de0f2e9557f90ec994/action_text-trix/app/assets/javascripts/trix.js#L2835-L2871","documentation":"During the DOM walk, when a node must be force-removed, DOMPurify calls remove(node) and then checks getParentNode(node). A detached, parentless root node cannot be removed via removeChild/remove (Element.remove() is a no-op without a parent), so DOMPurify cannot guarantee the node is actually detached from any live tree. Since the caller asked for in-place sanitization, returning a still-attached/unsafe node would be unsafe, so it throws this TypeError instead of returning anything.","triggerScenarios":"Calling DOMPurify.sanitize(rootNode, { ... , RETURN_DOM: true } / IN_PLACE mode) where the root itself is selected for removal during the walk (root-kill) AND the node has no parent, so Element.prototype.remove() cannot detach it.","commonSituations":"Sanitizing a freshly created (not yet appended) element whose root element itself turns out to be forbidden/clobbered; passing a document fragment or detached element as the sanitize root in in-place mode; framework code that sanitizes a live node before mounting it.","solutions":["Do not pass the problematic node as the in-place root; instead sanitize its serialized HTML: DOMPurify.sanitize(node.outerHTML) and rebuild the element from the result.","Append the node to a temporary detached container with a parent before in-place sanitization, so removal can succeed.","Check beforehand whether the root tag is allowed (ALLOWED_TAGS/ADD_TAGS) or the element is clobbered, and replace it before calling sanitize.","Catch this TypeError and fall back to string-based sanitization."],"exampleFix":"// before: detached root sanitized in place\nDOMPurify.sanitize(el, { RETURN_DOM: true, IN_PLACE: true }); // throws if el must be removed\n// after: sanitize via serialized HTML\nconst clean = DOMPurify.sanitize(el.outerHTML);\nconst safeEl = DOMPurify.sanitize(clean, { RETURN_DOM: true });","handlingStrategy":"validation","validationCode":"if (root.parentNode === null && needsInPlaceSanitize) {\n  // detached root: fall back to string sanitization\n  const safeHtml = DOMPurify.sanitize(root.outerHTML);\n}\n// also pre-check the root tag is allowed:\nconst tag = root.tagName.toLowerCase();\nif (!allowedTags.includes(tag)) { /* replace root before IN_PLACE sanitize */ }","typeGuard":"const isSafelyInPlaceSanitizable = (n) => n instanceof Node && n.parentNode !== null && typeof n.remove === 'function';","tryCatchPattern":"try {\n  DOMPurify.sanitize(root, { IN_PLACE: true });\n} catch (e) {\n  if (String(e.message).includes('could not be detached')) {\n    const safe = DOMPurify.sanitize(root.outerHTML); // fallback path\n  } else { throw e; }\n}","preventionTips":["Only use IN_PLACE on nodes that are attached (have a parent).","Verify the root tag is in the allowlist before in-place sanitizing.","Prefer string/RETURN_DOM sanitization for detached trees.","Check for clobbering names/ids on the root before passing it in."],"tags":["dompurify","dom-sanitization","in-place","dom-removal"],"backgroundTag":"unsanitizable-dom-node","analyzedSha":"470040131122bd44e269b4de0f2e9557f90ec994","analyzedAt":"2026-09-02T10:19:15.878Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-09T16:17:10.729Z"}