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.
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 validate URLs against a protocol allowlist, blocking
javascript:,vbscript:, and other dangerous protocols at multiple points (parse, render, and command execution). -
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”The isValidImageSrc function blocks dangerous protocols for image sources:
| 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 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; },});Both pasted and dropped images go through the same validation. Files with non-matching MIME types or exceeding the size limit are silently rejected.
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));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, TextAlign, Highlight) 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 text styling extensions 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/size validation | 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 | SVG blocked unless in allowedMimeTypes | Scan SVG content on server |
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.