Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/quill/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "quill",
"version": "2.0.3",
"version": "1.3.7-snyk-fix.1",
"description": "Your powerful, rich text editor",
"author": "Jason Chen <jhchen7@gmail.com>",
"homepage": "https://quilljs.com",
Expand Down
4 changes: 4 additions & 0 deletions packages/quill/src/blots/break.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ class Break extends EmbedBlot {
value() {
return '';
}

html() {
return '<br>';
}
}
Break.blotName = 'break';
Break.tagName = 'BR';
Expand Down
23 changes: 16 additions & 7 deletions packages/quill/src/core/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import Break from '../blots/break.js';
import CursorBlot from '../blots/cursor.js';
import type Scroll from '../blots/scroll.js';
import TextBlot, { escapeText } from '../blots/text.js';
import {
cloneWithSafeAttributes,
getSafeWrapperMarkup,
} from './html_escape.js';
import { Range } from './selection.js';

const ASCII = /^[ -~]*$/;
Expand Down Expand Up @@ -399,15 +403,20 @@ function convertHTML(
if (isRoot || blot.statics.blotName === 'list') {
return parts.join('');
}
const { outerHTML, innerHTML } = blot.domNode as Element;
const [start, end] = outerHTML.split(`>${innerHTML}<`);
// TODO cleanup
if (start === '<table') {
return `<table style="border: 1px solid #000;">${parts.join('')}<${end}`;
const domNode = blot.domNode as Element;
const [start, end] = getSafeWrapperMarkup(domNode);
if (domNode.tagName === 'TABLE') {
return `<table style="border: 1px solid #000;">${parts.join('')}${end}`;
}
return `${start}>${parts.join('')}<${end}`;
if (end === '') {
return `${start}${parts.join('')}`;
}
return `${start}${parts.join('')}${end}`;
}
if (blot.domNode instanceof Element) {
return cloneWithSafeAttributes(blot.domNode).outerHTML;
}
return blot.domNode instanceof Element ? blot.domNode.outerHTML : '';
return '';
}

function combineFormats(
Expand Down
76 changes: 76 additions & 0 deletions packages/quill/src/core/html_escape.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { escapeText } from '../blots/text.js';

/** HTML body / text node context (same as TextBlot escape). */
export const escapeHtml = escapeText;

/** Double-quoted attribute context. */
export function escapeAttribute(value: string): string {
return value.replace(/[&<>"'\n\r\t]/g, (ch) => {
switch (ch) {
case '&':
return '&amp;';
case '<':
return '&lt;';
case '>':
return '&gt;';
case '"':
return '&quot;';
case "'":
return '&#39;';
case '\n':
return '&#10;';
case '\r':
return '&#13;';
case '\t':
return '&#9;';
default:
return ch;
}
});
}

export function stripDangerousAttributes(element: Element): void {
for (const attr of Array.from(element.attributes)) {
const name = attr.name.toLowerCase();
if (name.startsWith('on')) {
element.removeAttribute(attr.name);
continue;
}
const val = attr.value;
if (
(name === 'href' || name === 'src' || name === 'xlink:href') &&
/^\s*javascript:/i.test(val)
) {
element.removeAttribute(attr.name);
}
}
}

const EXPORT_MARKER = '\uE000QUILL_HTML_EXPORT\uE000';

/**
* Safe open/close fragments for a non-void element by cloning the shell,
* stripping dangerous attributes, and replacing inner HTML with a marker.
*/
export function getSafeWrapperMarkup(domNode: Element): [string, string] {
const shell = domNode.cloneNode(false) as Element;
stripDangerousAttributes(shell);
shell.innerHTML = EXPORT_MARKER;
const outer = shell.outerHTML;
const idx = outer.indexOf(EXPORT_MARKER);
if (idx === -1) {
return [shell.outerHTML, ''];
}
return [outer.slice(0, idx), outer.slice(idx + EXPORT_MARKER.length)];
}

/** Clone subtree and strip event handlers / javascript: URLs from every element. */
export function cloneWithSafeAttributes(root: Element): Element {
const clone = root.cloneNode(true) as Element;
const walk = (el: Element) => {
stripDangerousAttributes(el);
Array.from(el.children).forEach((ch) => walk(ch));
};
walk(clone);
return clone;
}
9 changes: 7 additions & 2 deletions packages/quill/src/formats/formula.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Embed from '../blots/embed.js';
import { escapeHtml } from '../core/html_escape.js';

class Formula extends Embed {
static blotName = 'formula';
Expand Down Expand Up @@ -27,8 +28,12 @@ class Formula extends Embed {
}

html() {
const { formula } = this.value();
return `<span>${formula}</span>`;
const dom = this.domNode as Element;
const raw =
(typeof this.value() === 'string' ? this.value() : null) ??
dom.getAttribute('data-value') ??
'';
return `<span class="ql-formula">${escapeHtml(raw)}</span>`;
}
}

Expand Down
12 changes: 12 additions & 0 deletions packages/quill/src/formats/image.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { EmbedBlot } from 'parchment';
import { escapeAttribute } from '../core/html_escape.js';
import { sanitize } from './link.js';

const ATTRIBUTES = ['alt', 'height', 'width'];
Expand Down Expand Up @@ -52,6 +53,17 @@ class Image extends EmbedBlot {
super.format(name, value);
}
}

html() {
const src = Image.sanitize(this.domNode.getAttribute('src') || '');
const formats = Image.formats(this.domNode);
const parts = [`src="${escapeAttribute(src)}"`];
ATTRIBUTES.forEach((name) => {
const v = formats[name];
if (v) parts.push(`${name}="${escapeAttribute(v)}"`);
});
return `<img ${parts.join(' ')}>`;
}
}

export default Image;
6 changes: 4 additions & 2 deletions packages/quill/src/formats/video.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { BlockEmbed } from '../blots/block.js';
import { escapeAttribute, escapeHtml } from '../core/html_escape.js';
import Link from './link.js';

const ATTRIBUTES = ['height', 'width'];
Expand Down Expand Up @@ -51,8 +52,9 @@ class Video extends BlockEmbed {
}

html() {
const { video } = this.value();
return `<a href="${video}">${video}</a>`;
const raw = this.domNode.getAttribute('src') ?? '';
const safe = Video.sanitize(raw);
return `<a href="${escapeAttribute(safe)}">${escapeHtml(safe)}</a>`;
}
}

Expand Down
3 changes: 2 additions & 1 deletion packages/quill/src/modules/syntax.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ClassAttributor, Scope } from 'parchment';
import type { Blot, ScrollBlot } from 'parchment';
import Inline from '../blots/inline.js';
import Quill from '../core/quill.js';
import { escapeAttribute } from '../core/html_escape.js';
import Module from '../core/module.js';
import { blockDelta } from '../blots/block.js';
import BreakBlot from '../blots/break.js';
Expand Down Expand Up @@ -159,7 +160,7 @@ class SyntaxCodeBlockContainer extends CodeBlockContainer {
? SyntaxCodeBlock.formats(codeBlock.domNode)
: 'plain';

return `<pre data-language="${language}">\n${escapeText(
return `<pre data-language="${escapeAttribute(language)}">\n${escapeText(
this.code(index, length),
)}\n</pre>`;
}
Expand Down
72 changes: 72 additions & 0 deletions packages/quill/test/unit/core/editor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { SizeClass } from '../../../src/formats/size.js';
import Blockquote from '../../../src/formats/blockquote.js';
import IndentClass from '../../../src/formats/indent.js';
import { ColorClass } from '../../../src/formats/color.js';
import Formula from '../../../src/formats/formula.js';
import Quill from '../../../src/core.js';
import { normalizeHTML } from '../__helpers__/utils.js';

Expand Down Expand Up @@ -1409,5 +1410,76 @@ describe('Editor', () => {
expect(editor.getHTML(2, 7)).toEqual('<pre>\n123\n\n\n4\n</pre>');
expect(editor.getHTML(5, 7)).toEqual('<pre>\n\n\n\n4567\n</pre>');
});

test('video html export escapes href and text for XSS payloads (CVE-2025-15056)', () => {
const editor = createEditor(
new Delta().insert({ video: 'https://example.test/' }).insert('\n'),
);
const iframe = editor.scroll.domNode.querySelector(
'iframe.ql-video',
) as HTMLIFrameElement;
iframe.setAttribute(
'src',
'https://example.test/path?x=" onmouseover="alert(1)',
);
const html = editor.getHTML(0, editor.scroll.length() - 1);
expect(html).not.toMatch(/onmouseover/i);
expect(html).toContain('&quot;');
});

test('formula html export escapes injected markup (CVE-2025-15056)', () => {
const prevKatex = (window as unknown as { katex?: unknown }).katex;
(window as unknown as { katex: { render: () => void } }).katex = {
render: () => {},
};
const container = document.createElement('div');
document.body.appendChild(container);
const quill = new Quill(container, {
registry: createRegistry([
ListContainer,
List,
IndentClass,
Bold,
Image,
ColorClass,
Link,
FontClass,
Header,
Italic,
AlignClass,
Video,
Strike,
Underline,
CodeBlock,
CodeBlockContainer,
Blockquote,
SizeClass,
Formula,
]),
});
quill.setContents(
new Delta()
.insert({ formula: '</span><img src=x onerror=alert(1)>' })
.insert('\n'),
);
const html = quill.editor.getHTML(0, quill.getLength() - 1);
expect(html).not.toMatch(/<img/i);
expect(html).toContain('&lt;/span&gt;');
if (prevKatex !== undefined) {
(window as unknown as { katex: unknown }).katex = prevKatex;
} else {
delete (window as unknown as { katex?: unknown }).katex;
}
quill.container.remove();
});

test('getHTML strips event attributes from blockquote wrapper', () => {
const editor = createEditor(
'<blockquote onmouseover="alert(1)">Quill</blockquote>',
);
const html = editor.getHTML(0, 6);
expect(html).not.toMatch(/onmouseover/i);
expect(html).toContain('Quill');
});
});
});
4 changes: 2 additions & 2 deletions packages/quill/test/unit/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ export default defineConfig({
extensions: ['.ts', '.js'],
},
test: {
include: [resolve(__dirname, '**/*.spec.ts')],
include: ['test/unit/**/*.spec.ts'],
typecheck: {
enabled: true,
include: [resolve(__dirname, '**/*.test-d.ts')],
include: ['test/unit/**/*.test-d.ts'],
},
setupFiles: [
resolve(__dirname, '__helpers__/expect.ts'),
Expand Down