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.
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.
| Feature | Outbound | Directive needed |
|---|---|---|
| Core editing (default setup) | Nothing. No fetch, XHR or beacon; assets come from your origin | 'self' only |
| Toolbar icons | Inline data: URIs | img-src data: |
Theme / plugin CSS + injected <style> | Your origin, plus runtime style elements | style-src-elem 'self' (+ nonce) |
| Text formatting | Inline styling applied at runtime | style-src 'self' 'unsafe-inline' (measured; see below) |
| Video embed | YouTube / Vimeo player iframes | frame-src for those hosts |
| Bookmark cards | Favicon service (Google by default; configurable, and can be disabled with an empty string) | img-src for that host, or turn it off |
| PDF export | Loads the ~900 KB renderer on first use — from your own assets, served next to the editor | 'self' (no third-party entry needed) |
| AI, collaboration, uploads | Only the endpoints you configure | connect-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 table 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 on the host page were counted. The editable area is an iframe with its own document, and these numbers do not include its refusals — under style-src 'self' it adds 21 more, for 34 in all:
| Policy | Refusals | Editor |
|---|---|---|
style-src 'self' | 13 | editor builds and stays styled; all 13 are style-src-elem |
+ style-src-attr 'unsafe-inline' | 13 | removes nothing |
+ style-src-elem 'unsafe-inline' | 3 | chrome collapses — see the trap below |
style-src 'self' 'unsafe-inline' | 0 | correct |
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 — all 13 refusals in row one are style-src-elem, and adding style-src-attr 'unsafe-inline' removes none of them. An earlier version of this page recommended the attribute directive. Corrected 3 September 2026; re-measured on 9 September 2026, when the count fell from 49 to 12 as runtime styling moved out of the core; re-measured again on 12 September 2026 against 2.9.0 (core 2.6.0), which removed the last style-attribute refusal.
What those refusals now cost. Before 2.9.0, each refused <style> element meant rules that never applied: under style-src 'self'the editing surface lost its typography, gutters and table borders, and plugin panels rendered unstyled, with nothing thrown. From 2.9.0 the editor still inserts those elements first — so the browser still logs the refusals — but re-applies the same rules through the CSSOM, which CSP does not govern. Audited with the full plugin bundle under style-src 'self': 32 of the 34 injected stylesheets applied, with the same computed values as with no policy. The other two belong to rubyannotation and stateinspector, which 2.9.0 missed; they still render unstyled under a strict policy and are fixed for the next core build. Otherwise the refusals are console noise rather than a broken editor, so a strict style-src 'self' is usable; 'unsafe-inline'is still what silences them. · 2.9.0 release notes
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.
This page briefly told you to add 'unsafe-eval'. That is fixed, not forgotten. Between 8 and 9 September 2026 the shipped build genuinely required it: one line in our licensing code built a console.warncall as a string and handed it to the dynamic-code global, to keep that string out of the obfuscated output. The cost of that concealment was a policy weakened for every customer, and a failure mode that looked like success — the constructor threw, while the container, the full toolbar and the side panels all still rendered. A visual check passed.
It is now a direct call, the editor builds under plain script-src 'self', and the obfuscated output does not contain the string either way — the concealment the trick was buying was already being done by the obfuscator. The numbers in the table above are from the build that ships today, and a check in our test suite re-measures every row against the live site so this page cannot drift from the product again.
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.