Security

Content-Security-Policy for rich text editors

Editors inject stylesheets, set inline style attributes, embed iframes, and sometimes fetch libraries mid-session. Here is how to work out the policy you actually need instead of reaching for unsafe-inline.

8 min read

A rich text editor is one of the harder components to put behind a strict Content-Security-Policy. It injects stylesheets, it renders user HTML, it may embed third-party iframes, and some features load libraries on demand. Get the policy wrong and the failure is rarely a clean error — a button silently does nothing, or an export produces a blank file.

This is how to work out the policy your editor actually needs, rather than reaching for unsafe-inline everywhere and hoping.

Step one: enumerate, do not guess

Vendor documentation is often incomplete about outbound calls, and the answer changes per feature. Enumerate empirically: instrument fetch, XMLHttpRequest and sendBeacon before the editor loads, exercise every feature you intend to ship, then read the log. The method is written up in a separate article.

Do this per feature, because the interesting requests are conditional. An editor can be completely quiet at rest and still load a library the first time someone exports a PDF.

The directives that actually matter

style-src

Editors inject stylesheets at runtime — theme CSS, plugin CSS, and often a <style> element created in JavaScript for view features. They also set inline styleattributes constantly, because that is what “make this text red” means in a contenteditable.

Note the split: CSP3 style-src-attr governs inline style="" attributes, while style-src-elem governs <style> and <link>. A nonce covers injected <style> elements but does notcover style attributes. If you need user formatting to work, some inline-style concession is unavoidable — but do not assume the attribute directive is the one that buys it. Which directive a browser blames depends on how the editor applies the style, and that is a question to answer by measuring rather than by reading the spec. For this editor, measured, style-src-attr turns out to cover almost none of it; the numbers are below.

img-src

Two things beyond your own origin are common. Editors frequently inline toolbar icons as data: URIs, so img-src data:is usually required. And if users paste or upload images, decide deliberately whether remote images are allowed at all — img-src * is a privacy leak (it lets a pasted image beacon your users), while restricting to your own origin means you must proxy or re-host on paste.

frame-src

Video embed features insert third-party iframes — typically https://www.youtube.com/embed/… and https://player.vimeo.com/…. If you allow the feature, allow the frames; if you do not want the frames, turn the feature off rather than leaving a button that fails silently.

script-src

This is the one that surprises people. Some editor features load a third-party library on first use rather than bundling it — PDF export is the usual example, because the rendering libraries are large. That is a runtime script-src requirement that will not appear until someone clicks the button.

Any feature that pulls a script from a CDN at runtime is also a supply-chain dependency. Check whether the URL is version-pinned. A URL that points at a branch rather than a tagged release can change underneath you, and it is worth self-hosting that asset from your own origin instead — which also removes the script-src exception entirely.

A worked example

Here is the actual outbound surface of RichTextEditor, feature by feature, from instrumenting a default self-hosted setup and then each optional feature in turn.

FeatureOutboundDirective needed
Core editing (default setup)Nothing. No fetch, XHR or beacon; assets come from your origin'self' only
Toolbar iconsInline data: URIsimg-src data:
Theme / plugin CSS + injected <style>Your origin, plus runtime style elementsstyle-src-elem 'self' (+ nonce)
Text formattingInline styling applied at runtimestyle-src 'self' 'unsafe-inline' (measured; see below)
Video embedYouTube / Vimeo player iframesframe-src for those hosts
Bookmark cardsFavicon service (Google by default; configurable, and can be disabled with an empty string)img-src for that host, or turn it off
PDF exportLoads the ~900 KB renderer on first use — from your own assets, served next to the editor'self' (no third-party entry needed)
AI, collaboration, uploadsOnly the endpoints you configureconnect-src for your own services

The shape worth noticing: the base editor needs nothing beyond 'self', and every remaining external entry belongs to an optional feature you can disable or reconfigure. That is the property to test for in any editor you are evaluating — not “does it work under CSP” but “which exceptions does each feature cost me, and can I opt out?”

PDF export is a worked example of getting this wrong and then fixing it. It used to fetch its renderer from a GitHub raw-CDN URL that tracked a branch rather than a release — which meant a third-party script-srcentry, a hard failure in air-gapped deployments, and a dependency that could change with no version pin. The bundle is now pinned and shipped with the editor’s own assets, so exporting a PDF makes no external request at all. If you would rather serve it from your own CDN or a versioned asset path, html2pdfScriptUrl overrides the location.

A starting policy

Tighten from here rather than loosening into it. Drop the lines for features you do not ship, and replace CDN hosts with your own origin wherever you self-host the asset.

Content-Security-Policy:
  default-src 'self';
  script-src 'self';                    /* + CDN host only if PDF export is used */
  style-src 'self' 'unsafe-inline';     /* measured: see the note below */
  img-src 'self' data: blob:;           /* add upload/CDN origins as needed */
  frame-src https://www.youtube.com https://player.vimeo.com;
  connect-src 'self';                   /* + your AI / collab / upload endpoints */
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'self';

What this editor actually needs, measured

The split above is how CSP3 is specified. It is not, for this editor, how it behaves — so here are the numbers rather than the theory. Each policy was served to a page running the full editor and the console refusals were counted:

PolicyRefusalsEditor
style-src 'self'49renders, console full of errors
+ style-src-attr 'unsafe-inline'48removes exactly one refusal
+ style-src-elem 'unsafe-inline'8toolbar collapses — see the trap below
style-src 'self' 'unsafe-inline'0correct

So style-src-attr 'unsafe-inline'is not the narrow concession it looks like here: almost all of this editor’s runtime styling is applied in ways the browser attributes to style-src, not to the attribute directive. An earlier version of this page recommended it, and that recommendation removed one refusal out of forty-nine. Corrected 3 September 2026.

The trap in that third row. Naming style-src-elem replaces style-src for elements entirely — it does not add to it. Writing style-src-elem 'unsafe-inline' without also listing 'self' therefore blocks the theme stylesheet itself, and the editor renders with no styling at all: a 43,000-pixel column of unstyled buttons. If you specify the granular directives, list every source each one needs.

The editor still renders under the strict policy in row one, which is what makes this worth measuring rather than assuming: a CSP that is quietly wrong here does not produce a blank page, it produces a working toolbar and forty-nine console errors that nobody reads until a feature misbehaves.

Roll it out in report-only first

Editor CSP failures are quiet. Deploy with Content-Security-Policy-Report-Only and a report-toendpoint, leave it for a full usage cycle, and let real users find the feature you forgot. Exercise the long tail deliberately — export, print, embed, paste from Word, upload an image — because those are exactly the paths that reach for something extra.

Finally: CSP is a second line of defence, not a substitute for sanitising the HTML your editor stores and re-renders. It limits the damage of an XSS payload that already got through; it does not stop the payload being stored.


RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.