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.
For builds and Node.js server integrations, use Node.js 22.23.4+ within 22.x, 24.22.1+ within 24.x, or 26.12.1 or newer. Keep your chosen release line on its latest security patch. These minimums include the October 2026 Node.js security fixes, including CVE-2026-111497 in the native Brotli decoder.
OpenLeaf's browser bundles do not include Node.js. Updating an OpenLeaf package
or @types/node does not patch the Node.js executable used by a build or server.
Update that runtime separately, then restart affected Node.js processes.
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, colour and typography landed, the policy permits a style
attribute on paragraphs, headings and <span> -- for text-align, color,
background-color, font-family and font-size, with the values checked. It
has to: those declarations are how every editor OpenLeaf replaces expresses
alignment, colour and type, and a policy that forbids style outright deletes
them out of every document it touches.
font-family is an allowlist of family names, not a denylist of known-bad
functions. A name may contain letters, digits, spaces, hyphens, apostrophes
and plus, and may start with a digit when quoted (21st Century,
Goudy's Old Style). url(), expression(), var(), comments, unbalanced
quotes, backslash escapes, ; and newlines are refused. Accepted names are
re-emitted as a CSS identifier or a double-quoted string, which is the
spelling the toolbar options use.
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.
Approved players inside preserved wrappers use the same embed host/path and
permissions allowlists as modelled iframe nodes. A wrapper never grants permission
to embed an arbitrary page; srcdoc and event handlers are still stripped.
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?"
@openleaf-editor/plugins-webmcp registers a WebMCP tool set with the
browser, which lets an agent driving the page call into the editor. A few
things about it are worth stating before you install it.
It is opt-in and it is off. The package is not in the core bundle and
nothing calls installAgentTools() for you. A deployment that does not
install it has no agent surface at all, and the tools are additionally
inert in every browser that does not implement the API.
Installing is coarse; the permission predicate is not.
installAgentTools({ allowTool }) is asked before every tool call --
including openleaf_list_editors -- and is handed the tool's name, the
editor the call names, and whether the tool only reads. Answering false
returns refused to the agent and changes nothing, because the question
is asked before any argument is validated and before anything touches an
editor; a predicate that throws is treated the same way rather than
reaching the agent as a crashed call, and so is one that answers with
anything other than true -- the answer is compared with ===, so an
async predicate's Promise refuses rather than reading as a truthy yes. The setting is set-once and cannot
be cleared -- the script-tag spelling, OpenLeaf.registerAgentPermission,
is a function on the page's own global, so a later script would otherwise
be able to replace the policy or take it off. It is where the integrator
hosting the editor expresses a policy -- "read but do not write", "not this
editor" -- and it has the standing of every other client-side control in
this file: it is not a boundary against the page itself, because anything
running on the page can reach the tool handlers directly, and it is not a
substitute for authorising the write on your server.
The caller is the browser's own agent, not another origin. Cross-origin tool exposure is a separate mechanism and this package does not configure it, so the tools are reachable from this document and nowhere else.
Document content read back through a tool is untrusted, in a direction
the rest of this file does not cover. Everywhere else here, "untrusted"
means the HTML your server is about to store. A tool that returns document
content is untrusted in the other direction as well: an author — or
whoever pasted into the document before them — can leave text in it that
is aimed at the agent reading it. Tools that return content are annotated
untrustedContentHint, which is what tells the client driving the agent
to treat instructions found inside as data. openleaf_get_document, which
returns an editor's HTML, is annotated with it; so is openleaf_find_text,
which hands back the text around each match, and so is
openleaf_get_structure, whose outline is built from the document's own
headings — shorter than the document is not the same as safer than it.
openleaf_list_editors and openleaf_get_capabilities return identifiers,
accessible names, schema type names and command labels only, and so do the
three tools that write, which report an identifier and nothing of the
document; all of them are annotated the other way.
An agent that writes goes through the paste policy, and the ordering is
the point. openleaf_replace_at and openleaf_insert_html sanitize the
HTML they were given before they parse it, with the same
normalizePastedHtml the editor runs on a human paste — and with its one
lax branch taken away. That function picks a normalizer from what the
markup looks like, and markup that looks like a copy out of an OpenLeaf
editor keeps its inline styles, because a copy is in the same trust domain
as where it is going. Agent HTML never is, and the signal is an attribute
an agent can write into its own argument, so a write is routed to the
foreign-input normalizer whatever it claims to be. Parsing first
would be the hole: the preservation layer is a catch-all, so markup the
schema does not recognise is wrapped and kept rather than rejected, and
hostile or malformed input fed straight to the parser would become an
opaque atom the document then carries faithfully forever — preserved
because nothing could parse it. Sanitizing first means an agent can put
nothing into a document that a person could not have pasted into it, and
that is a client-side control with exactly the standing every other one in
this file has: it is a user-experience feature, not a substitute for
sanitizing on your server. A write whose range covers preserved markup is
refused outright, because the byte-identical promise only holds if nothing
edits in there, and a write that is refused for any reason changes nothing
at all.
A tool that writes has the reach of the toolbar, and no more.
openleaf_apply_command is the third tool that changes the document, and
the one that cannot write markup at all: it runs one of the commands the
deployment registered and the editor's own toolbar layout offers, so an
agent reaches exactly what a person clicking that bar reaches. The
refusals are the editor's own, not this package's -- a readonly editor,
an open HTML source view, and markup the preservation layer holds byte
for byte are all refused, because the toolbar is unavailable in the first
two and editing the third is the one thing that breaks the guarantee it
makes. It carries the readOnlyHint: false annotation, which is what
lets a client single it out as the call worth putting to a person.
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
preserve-styles is an explicit opt-in for trusted CMS module authors. It does
not change the default editor or sanitizer policy. Enabled editors keep CSS in
inert document metadata, render local rules through a per-editor CSS scope, and
emit style blocks on serialization. The CMS must choose and enforce its own
server-side CSS policy. Scoped rendering is not a security sandbox; CSS can
reference external resources. Script and unsafe-HTML filtering still applies.
The editing canvas does not activate document-global rules such as @import,
font definitions, keyframes, or property registrations. See
CMS module styles for the full
integration and round-trip contract.
canvas="iframe" provides layout isolation for full CMS editors. Its sandbox
allows same-origin access and scripts so parent-owned editing callbacks work in
WebKit. It is not a security boundary. Content enters through the ordinary
schema/URL filters, never through authored srcdoc or document.write; server
sanitization remains necessary. Trusted content-css URLs load inside the
frame and embedded CSS requires the existing preserve-styles opt-in.