A Rich-Text Editor with Safe HTML — Double Sanitization Against XSS

Balancing free-form editing with a pipeline that never lets dangerous tags through

rich-text editorTiptapXSS preventionsanitizationsecurity
5 min read

Introduction

For writing blog posts, a rich-text editor is a real comfort — bold text, headings, inline images, all right there. But that same "write whatever you like" quality is, if left alone, a security weak point.

This article covers how we kept dangerous code out without spoiling the writing experience. A few technical terms come up, but the underlying idea is simple.

Why an editor can become dangerous

The attack called XSS

Whatever you write in a rich-text editor eventually appears on the site as HTML. If a malicious program (a script) slips into an article, it runs automatically in the browser of anyone who reads it.

That's the attack known as XSS (cross-site scripting). It can steal login credentials or display fake input forms.

Deciding what's allowed, not what's banned

Hunting down dangerous tags and stripping them out (a blocklist) means new gaps open up every time a new attack technique appears. So we went the other way: list only what's permitted and throw away everything else (an allowlist).

In article bodies we permit paragraphs, line breaks, bold, italic, underline, strikethrough, blockquotes, horizontal rules, headings, lists, links, and images. Nothing else. Any other tag may sit in the stored article, but it disappears before the page is rendered.

Sanitizing twice

Check on save, check again on display

Inspection (sanitization) happens twice: once when an article is saved, and again when it's rendered on screen.

The double-sanitization flow
Write in the editor

The restrictions here exist for a comfortable writing experience, not as a safety guarantee

Sanitize on save

Every tag and attribute not on the allowlist is stripped before the content reaches the database

Sanitize again on display

We don't trust what's in the database; the content is re-inspected just before rendering

You might wonder why display-time checking is needed at all if we already checked on save. But consider a case where the database was modified directly after saving, or where older data saved under a different rule set is still sitting there. Display-time inspection is the last line of defense.

The editor's own restrictions are, by design, purely about writing comfort. The principle is: never treat a UI restriction as a security guarantee.

For link URLs we allow only ordinary web pages, email addresses, and phone numbers, and strip everything else. Certain exotic URL formats can carry scripts inside them.

For images we allow only encrypted-connection URLs. Formats that embed the data directly in the URL are also disallowed, since they can be abused.

On top of that, links to external sites automatically get safety attributes, and images automatically get lazy-loading applied. The writer doesn't have to think about it; the system puts things into the right shape.

The only styling allowed is text alignment

Text alignment (left, center, right) is a legitimate part of writing an article, so we allow it. Every other style declaration is blocked.

If you can position elements freely, you can do things like layering an invisible button over the page (clickjacking). It costs us some design flexibility, but article bodies don't need that much freedom in the first place.

Building convenient features safely

Shop cards embed nothing but an identifier

When you want to feature a local business in a post, you search for it from the editor and pick it, and a card-style element gets inserted into the article. But the only thing embedded in that element is the Google place identifier (Place ID).

The current shop name and rating are fetched fresh on every render, so the information never goes stale — and there's no room to embed malicious code in the article body.

We went a step further in the inspection stage: anything that isn't "a shop-card element whose identifier is in the correct format" gets deleted along with its contents. Building a lookalike element won't get you through.

Providing an escape hatch for editing HTML directly

For the occasional case a rich-text editor can't express, we added a mode for editing HTML directly. Content written there still passes through sanitization on save and on display.

More freedom, and the inspection is unavoidable. With that structure, offering an escape hatch doesn't change the safety picture at all.

Wrapping up

Three points anchored the editor's security.

  1. Allowlist approach — enumerate the permitted tags and discard everything else, unconditionally
  2. Double inspection on save and on display — don't even trust what's in the database
  3. Never treat a UI restriction as a safety guarantee — the editor's limits are about writing comfort, full stop