Skip to content

fix: DOMPurify hook memory leak in MarkdownText#38834

Merged
ggazzo merged 9 commits into
developfrom
copilot/fix-dompurify-memory-leak
Feb 20, 2026
Merged

fix: DOMPurify hook memory leak in MarkdownText#38834
ggazzo merged 9 commits into
developfrom
copilot/fix-dompurify-memory-leak

Conversation

Copilot AI commented Feb 20, 2026

Copy link
Copy Markdown
Contributor
  • Understand the issue: DOMPurify hook is being registered on every render in useMemo
  • Move the hook registration to module level to ensure it's registered only once globally
  • Use token-based approach for translations to enable contextualized t function
  • Generate token at runtime to prevent enumeration attacks
  • Update test to verify hook is registered at module level, not per-component
  • Fix code style issues (trailing whitespace)
  • Simplify code by removing unnecessary wrapper function and guard
  • Run code review - all issues addressed
  • Run security checks - no vulnerabilities

Summary

Fixed critical memory leak in MarkdownText.tsx by moving DOMPurify hook registration from useMemo (component level) to module level. Hook now registers once globally instead of per-component instance, eliminating accumulation of duplicate hooks (100+ → 1) and reducing hook calls during normal usage (5850+ → ~58).

Implementation Details

The fix uses a token-based approach with runtime generation to maintain both module-level hook registration (preventing memory leaks) and React's contextualized translation system:

  • Token Generation: Uses Math.random() to generate a unique, unpredictable token at runtime (e.g., __INTERNAL_LINK_TITLE_k3j9f2h8d__)
  • Hook (Module Level): Places the runtime-generated token for internal link titles during sanitization
  • Component (Per-Render): Performs string replacement after sanitization to inject contextualized translations using React's useTranslation() hook
  • Security: Runtime generation prevents enumeration attacks where predictable tokens could be exploited
  • Benefits: Combines memory leak prevention with proper i18n context support and enhanced security
Original prompt

This section details on the original issue you should resolve

<issue_title>DOMPurify hook leaks memory by re-registering on every render</issue_title>
<issue_description>### Description:

MarkdownText.tsx calls dompurify.addHook('afterSanitizeAttributes', ...) inside useMemo. dompurify.addHook appends to a global hook array and never deduplicates. This means a new hook copy is registered every time the useMemo re-runs (on each message render, content change, or language update), and these copies are never freed.

Steps to reproduce:

  1. Add these two debug lines to apps/meteor/client/components/MarkdownText.tsx
// Just before dompurify.addHook() 
console.count('addHook registered');

// First line inside the hook callback
console.count('addHook called');
  1. Open any chat room with messages
  2. Open browser DevTools and go to the Console tab
  3. Move the mouse over the chat and scroll

Expected behavior:

addHook registered: 1 - hook registered once, never again
addHook called - a small number proportional to links on the page

Actual behavior:

addHook registered grows to 100+ on page load (one per visible message)
addHook called reaches 5850+ from normal mouse movement

This is because 100+ duplicate hook copies all fire simultaneously on every sanitization pass.

Additional context

Image
</issue_description>

Comments on the Issue (you are @copilot in this section)


🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. Learn more about Advanced Security.

@dionisio-bot

dionisio-bot Bot commented Feb 20, 2026

Copy link
Copy Markdown
Contributor

Looks like this PR is not ready to merge, because of the following issues:

  • This PR is targeting the wrong base branch. It should target 8.3.0, but it targets 8.2.0

Please fix the issues and try again

If you have any trouble, please check the PR guidelines

@changeset-bot

changeset-bot Bot commented Feb 20, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: dac8fbc

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Copilot AI and others added 3 commits February 20, 2026 04:52
Co-authored-by: ggazzo <5263975+ggazzo@users.noreply.github.com>
…omponent

Co-authored-by: ggazzo <5263975+ggazzo@users.noreply.github.com>
Co-authored-by: ggazzo <5263975+ggazzo@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix memory leak caused by DOMPurify hook re-registration Fix DOMPurify hook memory leak in MarkdownText Feb 20, 2026
Copilot AI requested a review from ggazzo February 20, 2026 04:59
Comment on lines +111 to +114
const registerDOMPurifyHooks = () => {
if (hooksRegistered) {
return;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot is this really necessary? why not just register this function is not going to be run twice anyway

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right - the wrapper function and guard were unnecessary since the code only executes once at module level. Simplified in 63b8a94.

Co-authored-by: ggazzo <5263975+ggazzo@users.noreply.github.com>
Copilot AI requested a review from ggazzo February 20, 2026 13:54
// For internal links, add a translated title with the relative path
// Example: for href "https://my-server.rocket.chat/channel/general" the title would be "Go to #general"
// Using i18next directly ensures we always get the current translation
node.setAttribute('title', `${i18next.t('Go_to_href', { href: href.replace(getBaseURI(), '') })}`);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot I was thinking whether we could somehow place a random token here and perform the translation inside the component. That way, we can still use the contextualized t.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented token-based approach in 1a6e58f. The hook now places __INTERNAL_LINK_TITLE__ token for internal links, and the component performs string replacement using contextualized t() after sanitization. This keeps the hook at module level (no leak) while using React's translation context.

…function

Co-authored-by: ggazzo <5263975+ggazzo@users.noreply.github.com>

// Special token used to mark internal links that need translation
// This allows the hook to stay at module level while still using contextualized translations
const INTERNAL_LINK_TOKEN = '__INTERNAL_LINK_TITLE__';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot can we add some runtime generation here? to prevent any enumeration?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added runtime generation in 37a094a. The token is now generated using Math.random().toString(36) at module load time, creating a unique unpredictable string (e.g., __INTERNAL_LINK_TITLE_k3j9f2h8d__) that prevents enumeration attacks.

Co-authored-by: ggazzo <5263975+ggazzo@users.noreply.github.com>
@ggazzo ggazzo changed the title Fix DOMPurify hook memory leak in MarkdownText fix: DOMPurify hook memory leak in MarkdownText Feb 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📦 Docker Image Size Report

📈 Changes

Service Current Baseline Change Percent
sum of all images 1.1GiB 1.1GiB +11MiB
rocketchat 360MiB 349MiB +11MiB
omnichannel-transcript-service 134MiB 134MiB +69B
queue-worker-service 134MiB 134MiB -2.7KiB
ddp-streamer-service 128MiB 128MiB -113B
account-service 115MiB 115MiB +1.2KiB
authorization-service 112MiB 112MiB -2.6KiB
presence-service 112MiB 112MiB -437B

📊 Historical Trend

---
config:
  theme: "dark"
  xyChart:
    width: 900
    height: 400
---
xychart
  title "Image Size Evolution by Service (Last 30 Days + This PR)"
  x-axis ["12/01 23:01", "12/02 21:57", "12/03 21:00", "12/04 18:17", "12/05 21:56", "12/08 20:15", "12/09 22:17", "12/10 23:26", "12/11 21:56", "12/12 22:45", "12/13 01:34", "12/15 22:31", "12/16 22:18", "12/17 21:04", "12/18 23:12", "12/19 23:27", "12/20 21:03", "12/22 18:54", "12/23 16:16", "12/24 19:38", "12/25 17:51", "12/26 13:18", "12/29 19:01", "12/30 20:52", "02/12 22:57", "02/13 22:38", "02/16 14:04", "02/18 23:15", "02/19 23:23", "02/20 15:44", "02/20 15:52 (PR)"]
  y-axis "Size (GB)" 0 --> 0.5
  line "account-service" [0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11]
  line "authorization-service" [0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11]
  line "ddp-streamer-service" [0.12, 0.12, 0.12, 0.12, 0.12, 0.12, 0.12, 0.12, 0.12, 0.12, 0.12, 0.12, 0.12, 0.12, 0.12, 0.12, 0.12, 0.12, 0.12, 0.12, 0.12, 0.12, 0.12, 0.12, 0.12, 0.12, 0.12, 0.12, 0.12, 0.12, 0.12]
  line "omnichannel-transcript-service" [0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13]
  line "presence-service" [0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11, 0.11]
  line "queue-worker-service" [0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13, 0.13]
  line "rocketchat" [0.34, 0.34, 0.34, 0.34, 0.34, 0.34, 0.34, 0.34, 0.34, 0.34, 0.34, 0.34, 0.34, 0.34, 0.34, 0.34, 0.34, 0.34, 0.34, 0.34, 0.34, 0.34, 0.34, 0.34, 0.34, 0.34, 0.34, 0.34, 0.34, 0.34, 0.35]
Loading

Statistics (last 30 days):

  • 📊 Average: 1.5GiB
  • ⬇️ Minimum: 1.4GiB
  • ⬆️ Maximum: 1.6GiB
  • 🎯 Current PR: 1.1GiB
ℹ️ About this report

This report compares Docker image sizes from this build against the develop baseline.

  • Tag: pr-38834
  • Baseline: develop
  • Timestamp: 2026-02-20 15:52:24 UTC
  • Historical data points: 30

Updated: Fri, 20 Feb 2026 15:52:24 GMT

@ggazzo ggazzo marked this pull request as ready for review February 20, 2026 20:49
@ggazzo ggazzo requested a review from a team as a code owner February 20, 2026 20:49
@ggazzo ggazzo added this to the 8.3.0 milestone Feb 20, 2026
@ggazzo ggazzo added the stat: QA assured Means it has been tested and approved by a company insider label Feb 20, 2026
@dionisio-bot dionisio-bot Bot added the stat: ready to merge PR tested and approved waiting for merge label Feb 20, 2026
@ggazzo ggazzo merged commit a0285d1 into develop Feb 20, 2026
42 of 44 checks passed
@ggazzo ggazzo deleted the copilot/fix-dompurify-memory-leak branch February 20, 2026 20:55
@codecov

codecov Bot commented Feb 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.07843% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.58%. Comparing base (1710997) to head (dac8fbc).
⚠️ Report is 23 commits behind head on develop.

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##           develop   #38834      +/-   ##
===========================================
+ Coverage    70.53%   70.58%   +0.04%     
===========================================
  Files         3183     3184       +1     
  Lines       112485   112547      +62     
  Branches     20407    20405       -2     
===========================================
+ Hits         79338    79437      +99     
+ Misses       31087    31047      -40     
- Partials      2060     2063       +3     
Flag Coverage Δ
unit 71.54% <96.07%> (+0.07%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 2 files

@ggazzo

ggazzo commented Mar 20, 2026

Copy link
Copy Markdown
Member

/jira ARCH-2021

1 similar comment
@ggazzo

ggazzo commented Mar 20, 2026

Copy link
Copy Markdown
Member

/jira ARCH-2021

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stat: QA assured Means it has been tested and approved by a company insider stat: ready to merge PR tested and approved waiting for merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DOMPurify hook leaks memory by re-registering on every render

3 participants