← Blog

Sanitising Pasted HTML in a Rich-Text Editor

The first time a user pastes a paragraph from Microsoft Word into a rich-text editor you built, you realise how much HTML a single sentence can drag in — 50 kilobytes of inline styles, VML namespace declarations, mso-* attributes, empty wrapper spans, and a class name for every word. Pasted as-is into your document, that content breaks your layout, inherits the source's typography, and creates an XSS vector. This article walks through a sanitisation pipeline that keeps the structure users expect while throwing away the rest.

What "sanitise" actually means here

Two overlapping jobs:

  • Security: remove <script>, inline event handlers (onclick, onerror), javascript: URLs, and other XSS vectors. No paste handler can afford to skip this.
  • Normalisation: map the source's tags and attributes to your editor's schema. A <b> becomes a <strong>, or stays as <b>, depending on your model. Unsupported tags are dropped or unwrapped.

Security is the hard constraint; normalisation is a UX call. Both matter, but they are separate concerns and solving them in the same pass often produces subtle bugs.

Use DOMPurify, not a regex

Regex-based HTML sanitisation is wrong. HTML is not a regular language, and every "quick" sanitiser leaks somehow — nested comments, malformed tags, numeric entity encoding of dangerous characters. Use DOMPurify, which parses properly and has survived a decade of adversarial review.

import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(dirty, {
  ALLOWED_TAGS: ['p', 'strong', 'em', 'ul', 'ol', 'li', 'a', 'code', 'pre', 'blockquote', 'br'],
  ALLOWED_ATTR: ['href'],
  ALLOW_DATA_ATTR: false,
});

The whitelist above is intentionally minimal. A permissive whitelist that includes style lets through the Word styles you are trying to strip; a whitelist that includes class lets through application-specific class hooks that inherit from the source's CSS. Start restrictive, add only what you need.

Stripping Microsoft Office noise

Office HTML is wrapped in a scaffold of <!--StartFragment--> and <!--EndFragment--> comments, conditional-comment IE metadata, and the urn:schemas-microsoft-com:office:* namespace. DOMPurify removes the conditional comments and disallowed namespaces by default, but the mso-* CSS lives inside style attributes of the allowed tags. If you do not allow style, you are safe. If you need inline styles (for a "preserve colour and bold" feature), post-process each style attribute and drop any declaration whose property starts with mso-.

Dealing with Google Docs

Google Docs pastes are cleaner but sneak through custom inline styles that describe typography in pixel-perfect terms. A pasted paragraph often carries font-family: Arial; font-size: 11pt; color: #000 — three declarations that each override your editor's theme.

After DOMPurify, run a second pass over each element and whitelist only the CSS properties that matter to your app (typically nothing — let the editor's theme handle presentation). The typical whitelist is empty or text-align for preserved alignment.

Normalising to your schema

Rich-text editors like ProseMirror, Slate, and TipTap have a schema — an allowed set of node types and marks. The sanitised HTML still needs to be converted into that schema. Each editor ships a paste pipeline:

  • ProseMirror: DOMParser reads HTML through schema-aware rules you define; unknown nodes are parsed as their closest valid equivalent.
  • TipTap: uses ProseMirror under the hood and exposes the same paste pipeline through its extension API.
  • Slate: deserialize functions walk the DOM and map it into Slate's JSON tree.
  • Lexical: $generateNodesFromDOM produces Lexical nodes from a DOM subtree.

The right order: DOMPurify first, then your editor's parser. DOMPurify removes anything dangerous; the schema parser drops or unwraps anything not modeled in your editor.

Empty-paragraph removal

Pastes from any source regularly arrive with empty <p> or <div> wrappers that would show up as blank lines in your editor. After the schema normalisation pass, walk the resulting tree and drop any block node whose only content is whitespace or a <br> element. Users expect pasted content to be dense.

The plain-text fallback

If the HTML is so mangled that you cannot produce a usable tree, fall back to text/plain. Wrap each line in a paragraph node and call it a day. This is also the right default behaviour when the user holds Shift+Ctrl+V (or Cmd+Shift+V on macOS) — most editors treat that as "paste as plain text" and skip the HTML branch entirely.

Pasting images within rich text

An HTML paste from a web page includes <img> tags with remote src URLs. Three options:

  • Keep the remote URL. Simplest, but the image disappears if the source goes down or blocks hotlinking.
  • Fetch the URL and upload the bytes to your own storage. Requires CORS cooperation from the source.
  • Strip the image entirely during paste. Safe and predictable, but surprising for users.

For most production editors, option two is the right choice. The paste-images guide covers the fetch-and-upload path in detail.

Preserving links

The href attribute is usually the only paste-preserved attribute in the whitelist. Post-process links to:

  • Drop javascript: URLs (DOMPurify handles this automatically).
  • Convert protocol-relative links (//example.com/page) to absolute.
  • Add rel="nofollow noopener" for external links if that is your app's convention.

Testing the pipeline

Build a fixture of "real world" pastes: one from Word, one from Google Docs, one from Notion, one from Slack, one from a web page, one from VS Code. Run each through your sanitiser and snapshot the output. Any regression in sanitiser behaviour shows up as a changed snapshot, which catches the subtle bugs that only appear for specific source apps.

Include one XSS fixture — a paste crafted to trigger the vulnerability your sanitiser defends against. If the fixture's payload ever fires during the test, you regressed.

Observability

In production, the shape of the input to your sanitiser is a user-provided blob. Log the size of the input, the number of disallowed tags dropped, and the resulting output size (with sampling). A sudden jump in "percent of input dropped" is a great signal that some new paste source is hitting your app and your whitelist is too tight.

Inspecting the raw paste

If a specific source produces an unexpected result, paste the same content into Clipboard Inspector to see the full HTML before any sanitisation. The difference between the inspector's raw HTML and your editor's final output tells you exactly where a surprise was introduced — DOMPurify, the schema parser, or your post-processing step. For a broader overview of the HTML variation across sources, see why pasted text loses or keeps its formatting.