Skip to content

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.

Domternal uses defense in depth with three layers:

  1. 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.

  2. 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).

  3. Safe DOM APIs - UI components (suggestions, emoji picker, toolbar) use textContent instead of innerHTML when rendering user-provided data, preventing DOM-based XSS.

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:

ContentResult
<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 allowed
const 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 removed
editor.setContent('<p>Safe <strong>text</strong></p><script>alert(1)</script>');

The Link mark includes several protections:

Links validate URLs against a configurable protocol allowlist. By default, only http:, https:, mailto:, and tel: are allowed:

import { Link } from '@domternal/core';
// Default protocols
Link.configure({
protocols: ['http:', 'https:', 'mailto:', 'tel:'],
});

Any URL with a non-allowed protocol is rejected. This happens at three points:

  1. Parse time - <a href="javascript:alert(1)"> is rejected in parseHTML. The link mark is not created.
  2. Render time - If a link with an invalid protocol reaches renderHTML (defense in depth), the href attribute is removed.
  3. 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
// tel:+1234567890

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.

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');
},
});

The Image extension validates image sources against dangerous protocols:

The isValidImageSrc function blocks dangerous protocols for image sources:

ProtocolResult
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: URLs
Image.configure({
allowBase64: false, // blocks all data: URLs including data:image/
});

Validation happens at four points (defense in depth):

  1. Parse time - parseHTML rejects images with invalid src
  2. Render time - renderHTML replaces invalid src with empty string
  3. Command time - setImage validates before inserting
  4. Input rule time - Markdown syntax (![alt](url)) validates the URL

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.

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-validated
const html = editor.getHTML();
// SSR - same safety
import { 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.

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 (getJSON() / setContent(json)) is validated against the schema:

// Output - only schema-valid content
const json = editor.getJSON();
// Input - unknown node types are rejected
editor.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);
},
});

While Domternal provides strong content filtering, some security concerns are your responsibility:

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 content
const userHtml = '<p>User content</p><img src=x onerror=alert(1)>';
editor.setContent(DOMPurify.sanitize(userHtml));

Always validate content on the server:

// Server-side: validate and re-parse through schema
import { 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);
}

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.

When using image uploads, validate files on your server independently of client-side checks:

// Server-side upload handler
app.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 });
});
ItemBuilt-inYour responsibility
XSS via <script> tagsSchema strips unknown elementsSanitize untrusted HTML input
javascript: URLs in linksProtocol allowlist blocks them-
javascript: URLs in imagesProtocol blocklist blocks them-
onerror/onclick handlersStripped from outputSanitize untrusted HTML before parsing
Reverse tabnabbingrel="noopener noreferrer" auto-added-
Malicious file uploadsClient-side MIME/size validationServer-side content validation
data: image URLsAllowed by default (allowBase64: true), non-image data: always blockedSet allowBase64: false for strict mode
CSP complianceNo unsafe-eval neededstyle-src 'unsafe-inline' if using text styling extensions
Server-side content validation-Re-parse through schema on server
SVG script injectionSVG blocked unless in allowedMimeTypesScan SVG content on server

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.