minisiwyg-editor
v0.4.0
Published
A sub-6kb, zero-dependency WYSIWYG editor with built-in XSS protection
Maintainers
Readme
minisiwyg-editor
A sub-6kb gzipped, zero-dependency WYSIWYG editor with built-in XSS protection.
Spiritual successor to Pell (~1.2kb, 12k stars, abandoned with known XSS vulnerabilities). minisiwyg-editor treats security as architecture, not an afterthought. The sanitizer is built into the editor via a declarative policy engine, not bolted on as a dependency.
Live Demo
Try it in your browser: erikleon.github.io/minisiwyg-editor
The demo runs the full editor + toolbar in <6kb gzipped. Paste an XSS payload (<img src=x onerror=alert(1)>) and watch the sanitizer strip it in real time.
Features
- Tiny. <6kb gzipped total. 6kb hard limit enforced in CI.
- Zero runtime dependencies. Nothing to audit, nothing to break.
- XSS protection at every entry point. Whitelist-based HTML sanitizer blocks
javascript:,data:, event handlers, and encoded bypass attempts. Tested against OWASP XSS cheat sheet vectors. - Declarative policy. JSON-serializable rules define allowed tags, attributes, protocols, depth, and length. Store policies in a database, transmit them over the wire, validate them with a schema.
- Tree-shakeable exports. Import only what you need. The sanitizer works standalone without the editor.
- TypeScript-first. Full type definitions shipped with the package.
- CSP-safe. No inline styles, no
eval, noFunctionconstructor.
Quick Start
npm install minisiwyg-editorimport { createEditor } from 'minisiwyg-editor';
import { createToolbar } from 'minisiwyg-editor/toolbar';
const editor = createEditor(document.querySelector('#editor')!, {
onChange: (html) => console.log(html),
});
const toolbar = createToolbar(editor);
document.querySelector('#toolbar')!.appendChild(toolbar.element);Or use the sanitizer standalone, with no editor:
import { sanitize, DEFAULT_POLICY } from 'minisiwyg-editor/sanitize';
const dirty = '<p onclick="alert(1)">Hello <script>steal(cookies)</script><strong>world</strong></p>';
const clean = sanitize(dirty, DEFAULT_POLICY);
// → '<p>Hello <strong>world</strong></p>'Subpath Exports
minisiwyg-editor ships four independent modules. Each can be imported separately, and unused modules are tree-shaken out of your bundle.
| Export | Size (gzip) | Description |
|---|---|---|
| minisiwyg-editor/sanitize | ~1.6kb | Standalone HTML sanitizer. No DOM dependencies beyond <template>. |
| minisiwyg-editor/policy | ~0.3kb | MutationObserver-based runtime enforcement. Defense-in-depth. |
| minisiwyg-editor | ~1.6kb | contentEditable core with paste handler and formatting commands. |
| minisiwyg-editor/toolbar | ~0.1kb | Optional toolbar UI with ARIA roles and keyboard navigation. |
// Use just the sanitizer
import { sanitize, DEFAULT_POLICY } from 'minisiwyg-editor/sanitize';
// Use the full editor
import { createEditor } from 'minisiwyg-editor';
// Add the optional toolbar
import { createToolbar } from 'minisiwyg-editor/toolbar';Framework Adapters
Thin wrappers for React, Vue, and Svelte ship as optional subpath entries. They're peerDependencies — install only the framework you use.
React
import { Minisiwyg } from 'minisiwyg-editor/react';
function App() {
return (
<Minisiwyg
initialHTML="<p>hello</p>"
onChange={(html) => console.log(html)}
editorRef={(editor) => { /* call editor.exec(...), etc. */ }}
/>
);
}Vue 3
<script setup lang="ts">
import { Minisiwyg } from 'minisiwyg-editor/vue';
</script>
<template>
<Minisiwyg initialHTML="<p>hello</p>" @change="(html) => console.log(html)" />
</template>Svelte
Ships as a Svelte action so there's no compiler coupling:
<script lang="ts">
import { minisiwyg } from 'minisiwyg-editor/svelte';
</script>
<div use:minisiwyg={{ initialHTML: '<p>hello</p>', onChange: (html) => console.log(html) }}></div>All three adapters accept initialHTML, onChange, and a policy override. Pass value instead of initialHTML to opt in to controlled mode: the adapter reconciles the DOM only when value differs from the current editor HTML, so you avoid cursor jumps. Adapters are mount-once — changing policy after mount does not re-initialize the editor; remount via key (React/Vue) to reset.
Sanitizer
The sanitizer is the security core. It parses HTML via a <template> element (no script execution), walks the DOM tree depth-first, and removes anything not in the whitelist.
How It Works
- HTML is parsed into a DOM tree using
<template>(safe, no scripts execute) - The tree is walked depth-first, checking each node against the policy
- Disallowed tags are removed (strip mode) or unwrapped to plain text (unwrap mode)
- Disallowed attributes are stripped. Event handlers (
on*) are always stripped. - URL attributes (
href,src,action) are validated against allowed protocols javascript:anddata:URLs are hardcoded denials, they cannot be allowed via policy- Tags are normalized:
<b>becomes<strong>,<i>becomes<em> - Depth and length limits are enforced
Protocol Bypass Protection
The sanitizer decodes HTML entities (j to j), URL encoding (%6A to j), strips whitespace and control characters, and normalizes case before checking protocols. This blocks common XSS bypass techniques:
// All of these are blocked:
sanitize('<a href="javascript:alert(1)">', DEFAULT_POLICY); // direct
sanitize('<a href="JaVaScRiPt:alert(1)">', DEFAULT_POLICY); // mixed case
sanitize('<a href="javascript:alert(1)">', DEFAULT_POLICY); // HTML entities
sanitize('<a href="%6Aavascript:alert(1)">', DEFAULT_POLICY); // URL encoding
sanitize('<a href=" java\tscript:alert(1)">', DEFAULT_POLICY); // whitespacePolicy
A SanitizePolicy is a plain object that controls what HTML is allowed. It is JSON-serializable.
interface SanitizePolicy {
tags: Record<string, string[]>; // Allowed tags → allowed attributes
strip: boolean; // true: remove disallowed nodes. false: unwrap (keep text)
maxDepth: number; // Maximum nesting depth
maxLength: number; // Maximum text content length
protocols: string[]; // Allowed URL protocols (javascript/data always denied)
}DEFAULT_POLICY
The built-in default policy allows common formatting tags with sensible limits:
{
tags: {
p: [], br: [], strong: [], em: [],
a: ['href', 'title', 'target'],
h1: [], h2: [], h3: [],
ul: [], ol: [], li: [],
blockquote: [], pre: [], code: [],
},
strip: true,
maxDepth: 10,
maxLength: 100_000,
protocols: ['https', 'http', 'mailto'],
}DEFAULT_POLICY is deeply frozen at runtime. It cannot be mutated.
Custom Policies
import { sanitize } from 'minisiwyg-editor/sanitize';
import type { SanitizePolicy } from 'minisiwyg-editor/sanitize';
// Minimal: only bold and italic, no links
const strict: SanitizePolicy = {
tags: { strong: [], em: [] },
strip: false, // unwrap disallowed tags (keep text content)
maxDepth: 5,
maxLength: 10_000,
protocols: [], // no URL attributes allowed anyway
};
sanitize('<div><a href="https://example.com">Click <b>here</b></a></div>', strict);
// → 'Click <strong>here</strong>'// Permissive: allow images
const permissive: SanitizePolicy = {
tags: {
...DEFAULT_POLICY.tags,
img: ['src', 'alt', 'width', 'height'],
},
strip: true,
maxDepth: 15,
maxLength: 500_000,
protocols: ['https', 'http', 'mailto'],
};Editor API
import { createEditor } from 'minisiwyg-editor';
const editor = createEditor(element, {
policy: DEFAULT_POLICY, // optional, defaults to DEFAULT_POLICY
onChange: (html) => save(html), // optional change callback
});The returned Editor exposes:
| Method | Description |
|---|---|
| exec(command, value?) | Run a formatting command. See commands below. |
| queryState(command) | Returns true if the format is active at the cursor. |
| getHTML() | Returns the current sanitized HTML. |
| getText() | Returns the current text content. |
| on(event, handler) | Subscribe to change, paste, overflow, or error events. |
| element | The contentEditable HTML element backing the editor. |
| destroy() | Disconnect the observer and remove all listeners. |
Supported commands: bold, italic, underline, heading (with value '1', '2', or '3'), blockquote, unorderedList, orderedList, link (with URL value), unlink, codeBlock.
Toolbar
import { createToolbar } from 'minisiwyg-editor/toolbar';
const toolbar = createToolbar(editor, {
// Optional. Defaults to all built-in actions, grouped by '|' separators:
actions: ['bold', 'italic', 'underline', '|', 'heading', '|', 'unorderedList', 'orderedList', '|', 'link', 'codeBlock', '|', 'viewSource'],
});
document.body.appendChild(toolbar.element);The toolbar renders a <div role="toolbar"> containing <button> elements with aria-label and aria-pressed attributes. Arrow keys move focus between buttons; Tab exits the toolbar. The link button uses window.prompt() to collect a URL and validates it against the active policy's protocols. The viewSource button toggles a read-only <pre> showing the editor's current HTML; while active, the editor is hidden and other toolbar buttons are disabled. Call toolbar.destroy() to remove it.
Plugins
Plugins extend the editor without forking. A plugin is a plain object that can contribute tags/attributes/protocols to the sanitizer policy, register editor commands, and register toolbar actions. Pass the same plugins: [...] array to both createEditor and createToolbar.
import { createEditor } from 'minisiwyg-editor';
import { createToolbar } from 'minisiwyg-editor/toolbar';
import type { Plugin } from 'minisiwyg-editor';
const highlight: Plugin = {
name: 'highlight',
policy: { tags: { mark: [] } },
commands: {
highlight: {
exec(ctx) {
const sel = ctx.doc.getSelection();
if (!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
const mark = ctx.doc.createElement('mark');
mark.appendChild(range.extractContents());
range.insertNode(mark);
ctx.emit('change');
},
},
},
actions: {
highlight: { label: 'Highlight', icon: '<path d="..."/>' },
},
};
const editor = createEditor(element, { plugins: [highlight] });
const toolbar = createToolbar(editor, { plugins: [highlight] });
editor.exec('highlight');Plugin shape
interface Plugin {
name: string; // used for dedup + error messages
policy?: { // merged additively into the sanitizer policy
tags?: Record<string, string[]>; // tag → allowed attributes (keys must be lowercase)
protocols?: string[]; // unioned with policy.protocols
};
commands?: Record<string, {
exec(ctx: PluginContext, value?: string): void;
queryState?(ctx: PluginContext): boolean;
}>;
actions?: Record<string, {
label: string;
icon?: string; // raw inner-SVG markup
}>;
}
interface PluginContext {
readonly element: HTMLElement; // editor root
readonly doc: Document;
readonly policy: SanitizePolicy; // merged, post-plugin
emit(event: string, ...args: unknown[]): void;
}Safety rules
Plugin inputs go through the same enforcement as built-in config:
javascript:anddata:URLs remain hardcoded denials, regardless ofplugin.policy.protocols.- Plugin tag keys must be lowercase.
{ tags: { MARK: [] } }throws atcreateEditortime. - Duplicate command names throw at registration — whether across plugins or colliding with a built-in (
bold,italic,link, etc.). - Plugin policy deltas are merged before the MutationObserver and paste handler see the policy, so plugin-added tags go through the same whitelist.
Security Model
The editor has two layers of XSS protection:
Paste handler (primary boundary). When the user pastes content, it is intercepted, parsed via
<template>, sanitized through the policy, and inserted using the Selection/Range API. Dangerous content never enters the DOM.MutationObserver (defense-in-depth). A MutationObserver watches the contentEditable element for DOM mutations and removes anything that violates the policy. This catches edge cases where content enters through browser behavior (drag-and-drop, spell-check replacements, browser extensions).
The sanitizer itself is tested against OWASP XSS cheat sheet vectors in both happy-dom (fast unit tests) and Playwright (real browser tests).
What Is Always Blocked
<script>,<iframe>,<object>,<embed>,<style>,<form>tags- All event handler attributes (
onclick,onerror,onload, etc.) javascript:anddata:URLs, regardless of policy configuration- HTML entity, URL encoding, and mixed-case bypass attempts
- HTML comments and processing instructions
- Content beyond the configured depth and length limits
Browser Support
minisiwyg-editor requires browsers that support <template>, MutationObserver, and contentEditable. This covers all modern browsers:
- Chrome 26+
- Firefox 22+
- Safari 8+
- Edge 13+
Development
npm install # install dev dependencies
npm run build # esbuild: ESM + CJS output + type declarations
npm test # vitest with happy-dom
npx playwright test # OWASP XSS vectors in real browsers
npm run size-check # fails if total gzipped > 6kb
npm run typecheck # TypeScript type checkingRun a single test file:
npx vitest run test/sanitize.test.tsArchitecture
src/
types.ts Shared TypeScript interfaces (SanitizePolicy, Editor, Toolbar)
defaults.ts DEFAULT_POLICY (deep-frozen)
sanitize.ts DOM tree walker, whitelist engine, protocol validation
policy.ts MutationObserver wrapper, re-entrancy guard
editor.ts contentEditable core, paste handler, execCommand
toolbar.ts Optional ARIA toolbar UI
index.ts Re-exports for main entry pointBottom-up dependency chain: sanitize < policy < editor < toolbar. Each layer is tested independently before the next one builds on it.
License
MIT
