Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
# v2.0.4 (TBD)

## Security Fixes 🔒

- **CRITICAL**: Fixed XSS vulnerability in HTML export feature
- Formula embeds now properly escape user-controlled values in `html()` output
- Video embeds now properly escape URLs in `html()` output
- Prevents script injection when using `getSemanticHTML()` or editor's HTML output
- Applications using "export HTML → store → render" workflows are now protected
- **Impact**: Malicious formulas or video URLs could execute JavaScript when exported HTML is rendered
- **Fix**: All special HTML characters (`<`, `>`, `&`, `"`, `'`) are now escaped in formula and video embed output
- **CVE**: Pending assignment

## Additional Improvements

- Improved HTML validity by properly escaping special characters in formulas and video URLs
- Added comprehensive XSS prevention test coverage for formula and video embeds

# v2.0.2 (2024-05-13)

<!-- Release notes generated using configuration in .github/release.yml at v2.0.2 -->
Expand Down
7 changes: 5 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 { escapeText } from '../blots/text.js';

class Formula extends Embed {
static blotName = 'formula';
Expand Down Expand Up @@ -26,9 +27,11 @@ class Formula extends Embed {
return domNode.getAttribute('data-value');
}

domNode: HTMLElement;

html() {
const { formula } = this.value();
return `<span>${formula}</span>`;
const formula = Formula.value(this.domNode) || '';
return `<span>${escapeText(formula)}</span>`;
}
}

Expand Down
5 changes: 3 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 { escapeText } from '../blots/text.js';
import Link from './link.js';

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

html() {
const { video } = this.value();
return `<a href="${video}">${video}</a>`;
const video = Video.value(this.domNode) || '';
return `<a href="${escapeText(video)}">${escapeText(video)}</a>`;
}
}

Expand Down
120 changes: 120 additions & 0 deletions packages/quill/test/unit/formats/formula.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { describe, expect, test, beforeAll } from 'vitest';
import {
createScroll as baseCreateScroll,
createRegistry,
} from '../__helpers__/factory.js';
import Editor from '../../../src/core/editor.js';
import Formula from '../../../src/formats/formula.js';

const createScroll = (html: string) =>
baseCreateScroll(html, createRegistry([Formula]));

describe('Formula', () => {
// Mock KaTeX for tests
beforeAll(() => {
// @ts-expect-error - Mocking global
window.katex = {
render: () => {
// Mock render function
},
};
});

describe('XSS Prevention', () => {
test('escapes HTML tags in formula', () => {
const editor = new Editor(createScroll('<p><br></p>'));
const malicious = '<script>alert(1)</script>';
editor.insertEmbed(0, 'formula', malicious);
const html = editor.getHTML(0, 2);

// Should NOT contain unescaped HTML
expect(html).not.toContain('<script>');
expect(html).not.toContain('</script>');

// Should contain escaped version
expect(html).toContain('&lt;script&gt;');
expect(html).toContain('&lt;/script&gt;');
});

test('escapes malicious closing tags', () => {
const editor = new Editor(createScroll('<p><br></p>'));
const malicious = '</span><img src=x onerror=alert(1)>';
editor.insertEmbed(0, 'formula', malicious);
const html = editor.getHTML(0, 2);

// Should NOT contain unescaped closing tag or img tag
expect(html).not.toContain('</span><img');
// Should not have the img tag with onerror attribute (even if words appear escaped)
expect(html).not.toContain('<img');

// Should contain escaped version
expect(html).toContain('&lt;/span&gt;');
expect(html).toContain('&lt;img');
});

test('escapes quotes in formula', () => {
const editor = new Editor(createScroll('<p><br></p>'));
const malicious = '" onload="alert(1)';
editor.insertEmbed(0, 'formula', malicious);
const html = editor.getHTML(0, 2);

// Should contain escaped quotes
expect(html).toContain('&quot;');
// The word "onload" might appear but should be escaped, prevent the actual exploit
expect(html).not.toContain('" onload="');
});

test('escapes ampersands in formula', () => {
const editor = new Editor(createScroll('<p><br></p>'));
const formula = 'a & b';
editor.insertEmbed(0, 'formula', formula);
const html = editor.getHTML(0, 2);

// Should contain escaped ampersand
expect(html).toContain('&amp;');
});

test('escapes less than and greater than operators', () => {
const editor = new Editor(createScroll('<p><br></p>'));
const formula = 'x < 5 && y > 3';
editor.insertEmbed(0, 'formula', formula);
const html = editor.getHTML(0, 2);

// Should contain escaped operators (this also fixes invalid HTML)
expect(html).toContain('&lt;');
expect(html).toContain('&gt;');
expect(html).toContain('&amp;&amp;');
});

test('handles normal formulas without special characters', () => {
const editor = new Editor(createScroll('<p><br></p>'));
const formula = 'E=mc^2';
editor.insertEmbed(0, 'formula', formula);
const html = editor.getHTML(0, 2);

// Should contain the formula as-is (no special chars to escape)
expect(html).toContain('E=mc^2');
});

test('handles empty formula', () => {
const editor = new Editor(createScroll('<p><br></p>'));
editor.insertEmbed(0, 'formula', '');
const html = editor.getHTML(0, 2);

// Should create valid HTML even with empty formula
expect(html).toContain('<span></span>');
});

test('prevents double-escaping of already-escaped content', () => {
const editor = new Editor(createScroll('<p><br></p>'));
// User explicitly enters escaped content
const alreadyEscaped = '&lt;script&gt;';
editor.insertEmbed(0, 'formula', alreadyEscaped);
const html = editor.getHTML(0, 2);

// Should double-escape (correct behavior - user wanted literal text)
expect(html).toContain('&amp;lt;script&amp;gt;');
});
});
});

84 changes: 84 additions & 0 deletions packages/quill/test/unit/formats/video.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, expect, test } from 'vitest';
import {
createScroll as baseCreateScroll,
createRegistry,
} from '../__helpers__/factory.js';
import Editor from '../../../src/core/editor.js';
import Video from '../../../src/formats/video.js';

const createScroll = (html: string) =>
baseCreateScroll(html, createRegistry([Video]));

describe('Video', () => {
describe('XSS Prevention', () => {
test('escapes HTML tags in video URL', () => {
const editor = new Editor(createScroll('<p><br></p>'));
const malicious = '<script>alert(1)</script>';
editor.insertEmbed(0, 'video', malicious);
const html = editor.getHTML(0, 2);

// Should NOT contain unescaped HTML
expect(html).not.toContain('<script>');
expect(html).not.toContain('</script>');

// Should contain escaped version
expect(html).toContain('&lt;script&gt;');
expect(html).toContain('&lt;/script&gt;');
});

test('escapes malicious attributes in video URL', () => {
const editor = new Editor(createScroll('<p><br></p>'));
const malicious = '"><script>alert(1)</script><a href="';
editor.insertEmbed(0, 'video', malicious);
const html = editor.getHTML(0, 2);

// Should NOT contain unescaped script tag
expect(html).not.toContain('<script>');

// Should contain escaped version
expect(html).toContain('&lt;script&gt;');
});

test('escapes quotes in video URL', () => {
const editor = new Editor(createScroll('<p><br></p>'));
const malicious = '" onclick="alert(1)';
editor.insertEmbed(0, 'video', malicious);
const html = editor.getHTML(0, 2);

// Should contain escaped quotes
expect(html).toContain('&quot;');
// The word "onclick" will still appear but in escaped form
// What matters is the quotes are escaped, preventing attribute injection
expect(html).not.toContain('" onclick="');
});

test('escapes ampersands in video URL', () => {
const editor = new Editor(createScroll('<p><br></p>'));
const url = 'https://example.com?a=1&b=2';
editor.insertEmbed(0, 'video', url);
const html = editor.getHTML(0, 2);

// Should contain escaped ampersand in both href and text
expect(html).toContain('&amp;');
});

test('handles normal video URLs', () => {
const editor = new Editor(createScroll('<p><br></p>'));
const url = 'https://youtube.com/watch?v=abc123';
editor.insertEmbed(0, 'video', url);
const html = editor.getHTML(0, 2);

// Should contain the URL
expect(html).toContain('youtube.com/watch');
});

test('handles empty video URL', () => {
const editor = new Editor(createScroll('<p><br></p>'));
editor.insertEmbed(0, 'video', '');
const html = editor.getHTML(0, 2);

// Should create valid HTML even with empty URL
expect(html).toContain('<a href=""></a>');
});
});
});