Please do not open a public issue for anything exploitable. Use either of these, whichever works:
- GitHub private vulnerability reporting — the repository's "Security" tab, "Report a vulnerability".
- Email — peytonn98@googlemail.com, subject line starting
OPENLEAF SECURITY. No GPG key is published yet; if you need encryption, send an empty mail asking for one and we will arrange a channel before you send details.
Two channels rather than one, on purpose. A response commitment that depends on a single mechanism is only as good as that mechanism being switched on, and a researcher who finds the button missing and has been told not to open an issue is left with no route at all — which realistically ends in a dropped report or a full public disclosure. If option 1 is not available to you for any reason, option 2 always is.
We aim to acknowledge within 3 working days and to ship a fix or a documented mitigation within 30 days for anything rated high or above.
| Version | Supported |
|---|---|
0.1.0-beta.x |
Yes — current pre-release line |
| Anything earlier | No |
Every @openleaf-editor/* package shares one version number and is
released together, so a fix ships across the whole set. While the project
is 0.x there is no long-term support branch: fixes land on the current
line and you upgrade to get them. When a 1.0 line exists, this table
will say how long the previous major is patched for.
OpenLeaf is a client-side editor. Understanding this boundary matters:
Client-side sanitization is a user-experience feature, not a security control. Anything the editor strips can be re-added by a user with developer tools, because the editor runs entirely under their control.
You must sanitize on the server. @openleaf-editor/sanitize
ships the canonical allowlist as data (allowlist.json) precisely so that your
server-side sanitizer can enforce the same policy in the same terms, and
generates configuration for DOMPurify, Python's bleach and PHP's
HTMLPurifier from it. Using the editor's output as trusted HTML is a
vulnerability in your application, and no configuration of OpenLeaf can fix it.
Word .docx import is bounded before it is parsed. A .docx is a ZIP.
@openleaf-editor/plugins-import-docx refuses a file whose central directory
cannot be read, whose ZIP64 sentinels are not corroborated by a ZIP64 EOCD
locator immediately before the EOCD, whose local records are not packed
immediately before the central directory, or whose declared or actually inflated
size exceeds 256 MB (25 MB compressed). Forging a ZIP64 sentinel used to skip
the expansion ceiling; that path now fails closed. The check lives in
packages/plugins-import-docx/src/guards.ts.
We do not ship a novel sanitizer, on purpose. Sanitizers are defeated by
mutation XSS, by parser differentials between the sanitizer and the renderer,
and by SVG and MathML namespace confusion -- bug classes that take years of
adversarial attention to surface. DOMPurify has had that attention. So the
valuable artifact here is agreement: one policy that the editor, your Node
server, your PHP request handler and your Python worker all enforce identically,
instead of four hand-written allowlists that drift until something gets through
the weakest one. sanitizeHtml() is included for deployments that genuinely
cannot add a dependency, and its limitations are documented in the source rather
than glossed over.
OpenLeaf's preservation layer deliberately keeps markup the schema does not recognise, because silently deleting a customer's content is the failure this project exists to prevent. A default-safe sanitization policy will strip exactly that markup, destroying the content on the server that the editor worked to save -- the same bug wearing a different hat.
If you rely on preservation, extend the policy explicitly:
import { DEFAULT_POLICY, policyForPreserved } from '@openleaf-editor/sanitize'
const policy = policyForPreserved(DEFAULT_POLICY, {
div: ['class', 'data-callout-id'],
'drupal-media': ['data-entity-type', 'data-entity-uuid', 'data-view-mode'],
})There is deliberately no "allow whatever the editor emitted" mode. That is not a policy, it is a wish -- the editor faithfully preserves whatever an author pasted.
Since alignment and colour landed, the policy permits a style attribute on
paragraphs, headings and <span> -- for text-align, color and
background-color only, with the values checked. It has to: those declarations
are how every editor OpenLeaf replaces expresses alignment and colour, and a
policy that forbids style outright deletes them out of every document it
touches.
sanitizeHtml() and the emitted allowlist.json carry that precision per
element. The DOMPurify config cannot, because ALLOWED_ATTR is global and
DOMPurify performs no CSS property filtering of its own. Install the hook:
import DOMPurify from 'dompurify'
import { configureDOMPurify, DEFAULT_POLICY } from '@openleaf-editor/sanitize'
const purify = DOMPurify(window)
const config = configureDOMPurify(purify, DEFAULT_POLICY)
const clean = purify.sanitize(dirty, config)The lower-level toDOMPurifyConfig(DEFAULT_POLICY) fails closed by stripping
styles and iframes. Use configureDOMPurify to keep them: it installs the value
and host checks before returning a config that permits either feature. The
bleach and HTMLPurifier configs filter by property but not by element or value,
which is a narrower gap documented where each is emitted.
The policy permits an iframe only when its src is one of a closed list of
player hosts. That is a per-element host check, and no DOMPurify config can
express it: ALLOWED_URI_REGEXP applies to every URL attribute at once, so
narrowing it to YouTube would delete every ordinary link in the document.
So toDOMPurifyConfig(DEFAULT_POLICY) drops iframes. The recommended
configureDOMPurify call above installs embedHook(DEFAULT_POLICY) before it
enables them. Listing the element without that check would let
<iframe src="https://evil.example"> through the sanitizer this file
recommends -- a nested attacker-controlled page, which is the single thing the
embed allowlist exists to refuse. Omitting both is safe and loses stored embeds;
that is the right direction to fail.
The hook also filters allow. An allowlisted host is not sufficient on its own:
allow is how a frame asks to step outside the restrictions the rest of the page
lives under, so a permitted player URL carrying allow="camera; microphone"
would otherwise be handed the camera. sanitizeHtml() applies the same filter.
bleach cannot express the host list in configuration either, so
toBleachConfig emits a filter_embeds pre-pass to run alongside
drop_with_content. HTMLPurifier can: toHtmlPurifierConfig emits
HTML.SafeIframe and a generated URI.SafeIframeRegexp. It strips allow
outright, having no definition for it, so embeds arrive without their
permissions rather than with too many.
A plugin is same-trust as the page that loads it. Vet plugins as first-party code.
There is no plugin isolation of any kind — no iframe, no worker, no
capability object, no frozen surface. registerEditorPlugin,
registerToolbarItem and registerSchemaExtension are global,
unauthenticated registries, so any script already running on the page can
install a plugin. This is a normal design for an editor and the same one
every editor OpenLeaf replaces uses: a script on your page can already
read the DOM and issue requests as the user, so sandboxing the editor's
extension points would protect nothing that is not already lost. It is
written down here because "defensible" and "obvious" are different things.
Two consequences are specific to OpenLeaf and worth stating plainly.
The schema is the content policy at runtime. OpenLeaf's protection
against dangerous markup is that the schema does not model it — a
<script> becomes a preserved atom with its content scrubbed, because
nothing claims it. A schema extension's parseDOM and toDOM therefore
widen what markup is permitted, directly. A plugin that adds a node type
matching iframe has changed what the editor will store, for every
document, with no separate policy edit to review.
carryUnknownAttributes: false opts out of the attribute scrub. By
default an extension node's or mark's unmodelled attributes are captured on parse
and re-emitted on serialize, and that capture filters on* handlers and
unsafe URL schemes on the way through. Setting the flag to false skips
the wrapper entirely — including the filter. It exists for specs that
model every attribute they claim and would otherwise emit duplicates; it
is not a performance knob, and a plugin setting it is making a
security-relevant choice. The same capture is where an attribute name
that cannot be written back is refused, so opting out also opts out of
the check that keeps serializeHtml(parseHtml(x)) from throwing.
Neither of these is a reason not to use plugins. They are the reason the answer to "can I load this third-party plugin?" is the same as the answer to "can I add this third-party script tag?"
Everything above is about the sanitizer. A Content-Security-Policy on the pages that render editor output is the layer that holds when the sanitizer is wrong — and sanitizers are sometimes wrong, which is the premise of this whole file. It is cheap, and it contains an XSS that got through rather than merely logging it.
A reasonable baseline for a page rendering stored OpenLeaf content:
Content-Security-Policy:
default-src 'self';
script-src 'self';
object-src 'none';
base-uri 'none';
frame-src https://www.youtube-nocookie.com https://player.vimeo.com;
img-src 'self' https: data:;
style-src 'self';
require-trusted-types-for 'script';
Notes on the parts that matter:
script-srcwithout'unsafe-inline'is the whole point. An injected<img onerror>that survived sanitization does not run.object-src 'none'andbase-uri 'none'close two vectors that sanitizer bypasses reach for and that no page needs.frame-srcshould name the players you actually allow. It is a second, independent copy of the embed allowlist, enforced by the browser rather than by our code.style-src 'self'blocks inlinestyleattributes. OpenLeaf stores alignment and colour instyle, so this will visibly change how content renders — either add a nonce or hash for the styles you serve, or accept the loss knowingly. Do not reach for'unsafe-inline'onstyle-srcwithout noticing that it is the setting most commonly used to defeat a CSP by accident.
The editor itself is more constrained than the rendering page: it needs
style-src to apply the declarations an author sets while editing. If
you serve the editor under a strict style-src, expect alignment and
colour to stop applying live, and test it.
- XSS reachable through the editor's own parsing, serialization, or paste handling that would surprise a correctly-sanitizing server
- Executable content surviving a round trip through the editor. The
preservation layer keeps unrecognised markup, and it must never keep markup
that runs:
<script>, unallowlisted<iframe>,<object>,<form>, inlineon*handlers andjavascript:URLs are all dropped in@openleaf-editor/core, with tests.<iframe>is stored only forhttps:URLs on a closed list of player hosts. - The published allowlist permitting a construct that is unsafe to render
- Prototype pollution or code execution in the parsing path
- Content-destroying bugs in the preservation layer (we treat silent content loss as a security-grade defect)
- Resource exhaustion reachable from stored content. Anything an attacker can
put in a document must not be able to hang the tab that opens it, so the
parser bounds what it will build: nesting depth is refused with a named error
rather than overflowing the stack, and table cell spans are clamped to HTML's
own limits (
colspan1-1000,rowspan1-65534) because both consumers of a span scale linearly in it -- one<col>element per column, and a cell map ofwidth * heightentries. A row carries a cumulative column budget as well, because bounding one attribute does not bound their sum: 5,000 cells at the per-cell ceiling would otherwise reach the same five-million-column table by addition. A table ends up as wide as its markup, never wider. - Resource exhaustion reachable from a dropped
.docx. The Word importer measures expansion from the ZIP central directory and from inflate, and fails closed when the directory cannot be read -- including ZIP64 sentinels that are not backed by a real ZIP64 EOCD locator, and local records that are not packed immediately before the central directory.
- Un-sanitized editor output rendered as trusted HTML by an application
- Vulnerabilities in upstream
prosemirror-*packages (report upstream; tell us too so we can pin or patch) - Anything requiring the attacker to already control the page