Security
Domternal includes multiple layers of security to protect against XSS, injection attacks, and malicious content. This guide covers what’s built in, how the security model works, and what you need to handle on your side.
When to use
Section titled “When to use”Review Security before:
- Rendering user-generated HTML directly to other users (XSS risk)
- Accepting links from untrusted sources (
javascript:andvbscript:URLs are blocked but custom protocols may need review) - Allowing image base64 embedding (
allowBase64: trueon Image; controls DoS vectors) - Storing pasted HTML in a database that another user will later render
Notes:
- Domternal applies defense in depth per extension: Image validates at four layers (parseHTML, renderHTML, command, input rule); Link validates at three (parseHTML, renderHTML, command), plus its autolink and paste-as-link plugins
- For all other user-generated content, sanitize on the server before storing or rendering to other users
Security model
Section titled “Security model”Domternal uses defense in depth with three layers:
-
Schema-based filtering - ProseMirror’s schema acts as a content allowlist. Only nodes, marks, and attributes explicitly defined in your extensions are allowed. Everything else is stripped during parsing.
-
URL protocol validation - Links and images both validate URLs at multiple points, but they use opposite models. Links use an allowlist: only the protocols in
Link.options.protocols(http:,https:,mailto:,tel:by default) are accepted, and anything that is not an absolute URL is rejected. Images use a blocklist:javascript:,vbscript:andfile:are rejected,data:URLs pass only whenallowBase64istrueand the URL starts withdata:image/, and every other value is allowed: relative and protocol-relative URLs, but also schemes such asblob:orftp:. Links are validated at parse, render and command time (plus the autolink and paste plugins); images at parse, render, command and input-rule time. -
Safe DOM APIs - UI components (suggestions, emoji picker, toolbar) use
textContentinstead ofinnerHTMLwhen rendering user-provided data, preventing DOM-based XSS.
Schema as a security boundary
Section titled “Schema as a security boundary”ProseMirror’s schema is the primary security mechanism. When HTML is parsed into the editor, the schema determines what survives:
Allowed: Only nodes and marks you register via extensions. If you use StarterKit, that’s paragraphs, headings, lists, bold, italic, links, etc.
Stripped: Everything not in the schema. This includes:
| Content | Result |
|---|---|
<script>alert(1)</script> | Script tag removed, text content may remain |
<iframe src="..."> | Removed entirely |
<img onerror="alert(1)"> | Event handler stripped from output (not in schema attributes). Sanitize input to prevent execution during parsing |
<style>body{display:none}</style> | Removed entirely |
<div onclick="..."> | Unknown attributes stripped |
<a href="javascript:..."> | Link rejected by URL validation |
// Only extensions you add define what's allowedconst editor = new Editor({ extensions: [ Document, Paragraph, Text, Bold, Link, // No Image extension = <img> tags are stripped // No CodeBlock extension = <pre><code> tags are stripped ],});
// This HTML is parsed safely - unknown elements are removededitor.setContent('<p>Safe <strong>text</strong></p><script>alert(1)</script>');Link security
Section titled “Link security”The Link mark includes several protections:
Protocol allowlist
Section titled “Protocol allowlist”Links validate URLs against a configurable protocol allowlist. By default, only http:, https:, mailto:, and tel: are allowed:
import { Link } from '@domternal/core';
// Default protocolsLink.configure({ protocols: ['http:', 'https:', 'mailto:', 'tel:'],});Any URL with a non-allowed protocol is rejected. This happens at three points:
- Parse time -
<a href="javascript:alert(1)">is rejected inparseHTML. The link mark is not created. - Render time - If a link with an invalid protocol reaches
renderHTML(defense in depth), thehrefattribute is removed. - Command time -
editor.commands.setLink({ href: 'javascript:...' })validates before applying.
// All of these are blocked:// javascript:alert(1)// vbscript:MsgBox("XSS")// data:text/html,<script>alert(1)</script>
// These are allowed:// https://example.com// mailto:[email protected]// tel:+1234567890Automatic rel="noopener noreferrer"
Section titled “Automatic rel="noopener noreferrer"”Links with target="_blank" automatically get rel="noopener noreferrer" to prevent reverse tabnabbing attacks:
Link.configure({ addRelNoopener: true, // default});When enabled, any link that opens in a new tab (target="_blank") gets the protective rel attribute added during rendering. This prevents the opened page from accessing window.opener and potentially redirecting the original page.
Autolink and paste validation
Section titled “Autolink and paste validation”The autolink plugin (converts typed URLs to links) and the paste-as-link plugin both validate URLs against the same protocol allowlist before creating link marks. You can add custom validation for autolink:
Link.configure({ autolink: true, linkOnPaste: true, shouldAutoLink: (url) => { // Custom validation - block specific domains return !url.includes('malicious-site.com'); },});Image security
Section titled “Image security”The Image extension validates image sources against dangerous protocols:
Source URL validation
Section titled “Source URL validation”Image sources are validated internally against dangerous protocols:
| Protocol | Result |
|---|---|
http://, https:// | Allowed |
Relative paths (/img/photo.jpg) | Allowed |
Protocol-relative (//cdn.example.com/img.jpg) | Allowed |
javascript: | Blocked |
vbscript: | Blocked |
file: | Blocked |
data:image/png;base64,... | Allowed by default, blocked with allowBase64: false |
data:text/html,... | Always blocked (not data:image/) |
import { Image } from '@domternal/extension-image';
// Default: base64 images are allowed (data:image/* only)Image.configure({ allowBase64: true, // default - allows data:image/png, data:image/jpeg, etc.});
// Strict: block all data: URLsImage.configure({ allowBase64: false, // blocks all data: URLs including data:image/});Validation happens at four points (defense in depth):
- Parse time -
parseHTMLrejects images with invalidsrc - Render time -
renderHTMLreplaces invalidsrcwith empty string - Command time -
setImagevalidates before inserting - Input rule time - Markdown syntax (
) validates the URL
File upload validation
Section titled “File upload validation”When using the image upload handler, files are validated before processing:
Image.configure({ allowedMimeTypes: [ 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml', 'image/avif', ], maxFileSize: 10 * 1024 * 1024, // 10 MB. Not the default: maxFileSize defaults to 0 (unlimited) uploadHandler: async (file) => { // File is already validated for MIME type and size const formData = new FormData(); formData.append('file', file); const res = await fetch('/api/upload', { method: 'POST', body: formData }); const { url } = await res.json(); return url; },});Pasted and dropped images go through the same validation against allowedMimeTypes and maxFileSize. Files with a non-matching MIME type or exceeding the size limit are silently rejected.
Note the defaults: maxFileSize is 0, which means unlimited, so there is no client-side size cap until you set one. image/svg+xml is in the default allowedMimeTypes, so SVG uploads are accepted out of the box. See Image options for the full default list.
Images inserted through the toolbar’s file browser are not checked against allowedMimeTypes or maxFileSize. Enforce both on your server.
HTML output security
Section titled “HTML output security”The getHTML() method and generateHTML() function produce safe output because they serialize from the ProseMirror document (which only contains schema-validated content), not from raw HTML:
// Output is always schema-validatedconst html = editor.getHTML();
// SSR - same safetyimport { generateHTML } from '@domternal/core';const html = generateHTML(jsonContent, extensions);The output only contains nodes, marks, and attributes defined in your schema. Unknown content that was stripped during parsing cannot appear in the output.
Inline styles for email
Section titled “Inline styles for email”The inlineStyles() utility converts CSS classes to inline styles for email-safe HTML. It operates on parsed DOM elements and does not introduce any user-controlled content:
import { inlineStyles } from '@domternal/core';
const emailHtml = inlineStyles(html);JSON content safety
Section titled “JSON content safety”JSON content (getJSON() / setContent(json)) is validated against the schema:
// Output - only schema-valid contentconst json = editor.getJSON();
// Input - unknown node types are rejectededitor.setContent({ type: 'doc', content: [ { type: 'paragraph', content: [{ type: 'text', text: 'Safe' }] }, { type: 'malicious_node' }, // Rejected - not in schema ],});Invalid content triggers a contentError event instead of throwing:
const editor = new Editor({ extensions: [StarterKit], onContentError: ({ error }) => { console.warn('Invalid content:', error.message); },});What you need to handle
Section titled “What you need to handle”While Domternal provides strong content filtering, some security concerns are your responsibility:
Sanitize untrusted HTML input
Section titled “Sanitize untrusted HTML input”If you accept HTML from untrusted sources (user input, external APIs, databases), sanitize it before passing to the editor:
import DOMPurify from 'dompurify';
// Sanitize before setting contentconst userHtml = '<p>User content</p><img src=x onerror=alert(1)>';editor.setContent(DOMPurify.sanitize(userHtml));Treat custom icons as code
Section titled “Treat custom icons as code”A custom IconSet is injected as raw HTML, not escaped, because icons are SVG markup. The vanilla and Vue toolbars assign it with innerHTML, the React toolbar passes it through dangerouslySetInnerHTML, and the Angular toolbar runs it through bypassSecurityTrustHtml. This is deliberate, and it means an icon string is executable markup: never build an IconSet from user input, a database column, or a remote API response. The same applies to the optional style string on a toolbar item, which is written straight into the button’s style attribute. See Toolbar for how custom icon sets are passed in.
Server-side validation
Section titled “Server-side validation”Always validate content on the server:
// Server-side: validate and re-parse through schemaimport { generateJSON, generateHTML } from '@domternal/core';
function sanitizeContent(html: string, extensions: Extension[]): string { // Parse HTML through the schema (strips unknown content) const json = generateJSON(html, extensions); // Re-generate clean HTML return generateHTML(json, extensions);}Content Security Policy
Section titled “Content Security Policy”Domternal does not require unsafe-eval in your CSP. However, extensions that render inline style attributes (TextColor, FontFamily, FontSize, LineHeight, TextAlign, Highlight, floated images, and table cell background colors) do require style-src 'unsafe-inline' if your CSP restricts inline styles. The theme stylesheet itself loads from a file, not inline. If you use a strict style-src policy without 'unsafe-inline', avoid these features or use CSS classes instead of inline styles.
Upload handler security
Section titled “Upload handler security”When using image uploads, validate files on your server independently of client-side checks:
// Server-side upload handlerapp.post('/api/upload', async (req, res) => { const file = req.file;
// Validate MIME type by reading file header (magic bytes) // Don't trust the client-reported Content-Type const type = await detectFileType(file.buffer); if (!['image/jpeg', 'image/png', 'image/webp'].includes(type)) { return res.status(400).json({ error: 'Invalid file type' }); }
// Scan for embedded scripts in SVG files if (type === 'image/svg+xml') { const svg = file.buffer.toString('utf-8'); if (/<script/i.test(svg) || /on\w+\s*=/i.test(svg)) { return res.status(400).json({ error: 'SVG contains scripts' }); } }
// Store and return URL const url = await storeFile(file); res.json({ url });});Security checklist
Section titled “Security checklist”| Item | Built-in | Your responsibility |
|---|---|---|
XSS via <script> tags | Schema strips unknown elements | Sanitize untrusted HTML input |
javascript: URLs in links | Protocol allowlist blocks them | - |
javascript: URLs in images | Protocol blocklist blocks them | - |
onerror/onclick handlers | Stripped from output | Sanitize untrusted HTML before parsing |
| Reverse tabnabbing | rel="noopener noreferrer" auto-added | - |
| Malicious file uploads | Client-side MIME validation on paste/drop; size validation only once you set maxFileSize (default 0 = unlimited); the file browser applies neither | Server-side content validation |
data: image URLs | Allowed by default (allowBase64: true), non-image data: always blocked | Set allowBase64: false for strict mode |
| CSP compliance | No unsafe-eval needed | style-src 'unsafe-inline' if using text styling extensions |
| Server-side content validation | - | Re-parse through schema on server |
| SVG script injection | Allowed by default (image/svg+xml is in the default allowedMimeTypes); remove it to block SVG paste and drop uploads | Scan SVG content on server; dropping the MIME type does not block SVG URLs passed to setImage or pasted as HTML/Markdown |
Custom toolbar icons / item style | - | Injected as raw HTML; only use trusted, developer-authored strings |
Summary
Section titled “Summary”Domternal’s security model combines ProseMirror’s schema-based content filtering with URL protocol validation and safe DOM APIs. The schema acts as an allowlist: only content you explicitly register via extensions is allowed. URLs are validated at multiple points (parse, render, command) for defense in depth.
For maximum security: sanitize untrusted HTML with DOMPurify before passing it to the editor, validate uploads on your server, and re-parse content through the schema on the server side before storing or displaying it.
See also
Section titled “See also”- Link - hyperlinks with URL validation and paste handling
- Image - resizable images with float controls and upload
- Link Popover - floating UI for editing link URLs