basecamp/trix · critical

A configured TRUSTED_TYPES_POLICY callback (createHTML or cr

Error message

A configured TRUSTED_TYPES_POLICY callback (createHTML or createScriptURL) must not call DOMPurify.sanitize, as that causes infinite recursion. Do not pass a policy whose callbacks wrap DOMPurify as TRUSTED_TYPES_POLICY; see the "DOMPurify and Trusted Types" section (truncated in region)

What it means

DOMPurify tracks re-entrancy with an IN_TRUSTED_TYPES_POLICY counter that is incremented while a user-supplied TRUSTED_TYPES_POLICY createHTML/createScriptURL callback runs. If such a callback calls DOMPurify.sanitize, sanitize would invoke the policy again, recursing without bound. The shared guard detects the re-entry and throws this TypeError immediately instead of blowing the stack.

Source

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

    let trustedTypesPolicy;
    let emptyHTML = '';
    // The instance's own internal Trusted Types policy. Unlike a caller-supplied
    // `TRUSTED_TYPES_POLICY`, this is created at most once — Trusted Types throws
    // on duplicate policy names — and is the only policy allowed to persist
    // across configurations and survive `clearConfig()`.
    let defaultTrustedTypesPolicy;
    let defaultTrustedTypesPolicyResolved = false;
    // Tracks whether we are already inside a call to the configured Trusted Types
    // policy (`createHTML` or `createScriptURL`). If a supplied policy callback
    // itself calls `DOMPurify.sanitize` (the cause of #1422), `sanitize` would
    // re-enter the policy and recurse until the stack overflows. We detect that
    // re-entry and throw a clear, actionable error instead. The guard is shared
    // across both callbacks, because either one re-entering `sanitize` triggers
    // the same unbounded recursion.
    let IN_TRUSTED_TYPES_POLICY = 0;
    const _assertNotInTrustedTypesPolicy = function _assertNotInTrustedTypesPolicy() {
      if (IN_TRUSTED_TYPES_POLICY > 0) {
        throw typeErrorCreate('A configured TRUSTED_TYPES_POLICY callback (createHTML or ' + 'createScriptURL) must not call DOMPurify.sanitize, as that causes ' + 'infinite recursion. Do not pass a policy whose callbacks wrap ' + 'DOMPurify as TRUSTED_TYPES_POLICY; see the "DOMPurify and Trusted ' + 'Types" section of the README.');
      }
    };
    const _createTrustedHTML = function _createTrustedHTML(html) {
      _assertNotInTrustedTypesPolicy();
      IN_TRUSTED_TYPES_POLICY++;
      try {
        return trustedTypesPolicy.createHTML(html);
      } finally {
        IN_TRUSTED_TYPES_POLICY--;
      }
    };
    const _createTrustedScriptURL = function _createTrustedScriptURL(scriptUrl) {
      _assertNotInTrustedTypesPolicy();
      IN_TRUSTED_TYPES_POLICY++;
      try {
        return trustedTypesPolicy.createScriptURL(scriptUrl);
      } finally {
        IN_TRUSTED_TYPES_POLICY--;

View on GitHub (pinned to 4700401311)

Solutions

  1. Remove the DOMPurify.sanitize call from inside the policy callback; a createHTML callback must be a pure signer (e.g. identity or pass-through to trustedTypes), not a sanitizer.
  2. Do any sanitizing before/outside the policy, and let the policy only transform the already-sanitized string.
  3. Read the 'DOMPurify and Trusted Types' section of the README and follow its recommended policy shape.
  4. If recursive sanitization is genuinely needed, restructure it with two separate DOMPurify instances/entry points so the policy never re-enters sanitize.

Example fix

// before: policy re-enters sanitize -> infinite recursion
const policy = {
  createHTML: (dirty) => DOMPurify.sanitize(dirty),
  createScriptURL: (s) => s
};
DOMPurify.sanitize(input, { TRUSTED_TYPES_POLICY: policy });
// after: policy is a pure signer
const policy = {
  createHTML: (clean) => clean,
  createScriptURL: (s) => s
};
DOMPurify.sanitize(input, { TRUSTED_TYPES_POLICY: policy, RETURN_TRUSTED_TYPE: true });
Defensive patterns

Strategy: validation

Validate before calling

function assertPolicyIsPure(policy) {
  const src = String(policy.createHTML);
  if (src.includes('DOMPurify.sanitize') || src.includes('DOMPurify')) {
    throw new Error('TRUSTED_TYPES_POLICY.createHTML must not call DOMPurify.sanitize');
  }
  if (typeof policy.createScriptURL !== 'function' || typeof policy.createHTML !== 'function') {
    throw new Error('policy must define createHTML and createScriptURL');
  }
}
// call before: assertPolicyIsUseable(myPolicy);

Type guard

const isPurePolicy = (p) =>
  !!p && typeof p.createHTML === 'function' && typeof p.createScriptURL === 'function' &&
  !String(p.createHTML).includes('sanitize');

Try / catch

try {
  DOMPurify.sanitize(dirty, { TRUSTED_TYPES_POLICY: policy, RETURN_TRUSTED_TYPE: true });
} catch (e) {
  if (String(e.message).includes('infinite recursion')) {
    console.error('Policy re-enters sanitize; replace with a pure signer policy.');
  } else { throw e; }
}

Prevention

When it happens

Trigger: Passing a TRUSTED_TYPES_POLICY whose createHTML (or createScriptURL) implementation itself calls DOMPurify.sanitize on its input, then calling DOMPurify.sanitize with RETURN_TRUSTED_TYPE (or any path that signs output through the policy).

Common situations: Developers copying an example where the policy 'wraps' sanitize to double-sanitize; refactoring sanitize() into a helper used both in app code and inside the policy callback; confusing TRUSTED_TYPES_POLICY with a normal afterSanitize hook and putting sanitization logic there.

Related errors


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