From 17ec9ff0bda0381a4386d67e198054bf085fbc26 Mon Sep 17 00:00:00 2001 From: bdbch Date: Tue, 8 Sep 2026 09:30:51 +0200 Subject: [PATCH 1/2] fix: validate CSS style attribute values - Prevent injected CSS declarations from editor content - Reuse shared validation across style-related extensions --- .../2026-09-08-validate-css-style-values.md | 9 +++ packages/core/src/utilities/index.ts | 1 + .../utilities/isValidCSSStyleValue.test.ts | 22 +++++++ .../src/utilities/isValidCSSStyleValue.ts | 8 +++ .../src/collaboration-caret.test.ts | 6 ++ .../src/collaboration-caret.ts | 11 ++-- .../src/lib/isValidColor.test.ts | 29 --------- .../src/lib/isValidColor.ts | 3 - .../extension-highlight/src/highlight.test.ts | 44 ++++++++++++++ packages/extension-highlight/src/highlight.ts | 11 +++- .../extension-table/src/utils/parseAlign.ts | 12 ++-- .../tableCellAlignValidation.test.ts | 57 ++++++++++++++++++ .../src/text-align.test.ts | 41 +++++++++++++ .../extension-text-align/src/text-align.ts | 7 ++- .../src/background-color/background-color.ts | 4 +- .../extension-text-style/src/color/color.ts | 4 +- .../src/font-family/font-family.ts | 4 +- .../src/font-size/font-size.ts | 4 +- .../src/line-height/line-height.ts | 4 +- .../styleAttributeValidation.test.ts | 59 +++++++++++++++++++ 20 files changed, 285 insertions(+), 55 deletions(-) create mode 100644 .changeset/2026-09-08-validate-css-style-values.md create mode 100644 packages/core/src/utilities/isValidCSSStyleValue.test.ts create mode 100644 packages/core/src/utilities/isValidCSSStyleValue.ts delete mode 100644 packages/extension-collaboration-caret/src/lib/isValidColor.test.ts delete mode 100644 packages/extension-collaboration-caret/src/lib/isValidColor.ts create mode 100644 packages/extension-highlight/src/highlight.test.ts create mode 100644 packages/extension-table/tests/integration/tableCellAlignValidation.test.ts create mode 100644 packages/extension-text-align/src/text-align.test.ts create mode 100644 packages/extension-text-style/tests/integration/styleAttributeValidation.test.ts diff --git a/.changeset/2026-09-08-validate-css-style-values.md b/.changeset/2026-09-08-validate-css-style-values.md new file mode 100644 index 0000000000..98dc37527a --- /dev/null +++ b/.changeset/2026-09-08-validate-css-style-values.md @@ -0,0 +1,9 @@ +--- +'@tiptap/core': patch +'@tiptap/extension-highlight': patch +'@tiptap/extension-table': patch +'@tiptap/extension-text-align': patch +'@tiptap/extension-text-style': patch +--- + +Prevent extra CSS declarations from being rendered from editor content. diff --git a/packages/core/src/utilities/index.ts b/packages/core/src/utilities/index.ts index 83960f4914..9d22505e80 100644 --- a/packages/core/src/utilities/index.ts +++ b/packages/core/src/utilities/index.ts @@ -20,6 +20,7 @@ export * from './isPlainObject.js' export * from './isRegExp.js' export * from './isSafari.js' export * from './isString.js' +export * from './isValidCSSStyleValue.js' export * from './markdown/index.js' export * as markdown from './markdown/index.js' export * from './marksEqual.js' diff --git a/packages/core/src/utilities/isValidCSSStyleValue.test.ts b/packages/core/src/utilities/isValidCSSStyleValue.test.ts new file mode 100644 index 0000000000..99063b84f2 --- /dev/null +++ b/packages/core/src/utilities/isValidCSSStyleValue.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from 'vite-plus/test' + +import { isValidCSSStyleValue } from './isValidCSSStyleValue.js' + +describe('isValidCSSStyleValue', () => { + test.each([ + '#ffffff', + 'rgb(255, 0, 0)', + 'var(--text-color)', + '"Comic Sans MS", sans-serif', + 'calc(1rem + 2px)', + ])('accepts a single CSS value', value => { + expect(isValidCSSStyleValue(value)).toBe(true) + }) + + test.each([null, undefined, 16, '', ' ', 'red; position: fixed', 'red } body { display: none'])( + 'rejects a value that can escape its declaration', + value => { + expect(isValidCSSStyleValue(value)).toBe(false) + }, + ) +}) diff --git a/packages/core/src/utilities/isValidCSSStyleValue.ts b/packages/core/src/utilities/isValidCSSStyleValue.ts new file mode 100644 index 0000000000..230c721f0d --- /dev/null +++ b/packages/core/src/utilities/isValidCSSStyleValue.ts @@ -0,0 +1,8 @@ +/** + * Checks whether a value can be inserted into one CSS declaration. + * @param value - The CSS property value. + * @returns Whether the value stays inside one declaration. + */ +export function isValidCSSStyleValue(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0 && !/[;{}]/.test(value) +} diff --git a/packages/extension-collaboration-caret/src/collaboration-caret.test.ts b/packages/extension-collaboration-caret/src/collaboration-caret.test.ts index 6179125c78..b4e8213dbe 100644 --- a/packages/extension-collaboration-caret/src/collaboration-caret.test.ts +++ b/packages/extension-collaboration-caret/src/collaboration-caret.test.ts @@ -25,6 +25,12 @@ describe('default collaboration caret renderers', () => { expect(attributes.style).toBeUndefined() }) + test.each(['#fff', 'red', 'rgb(255, 0, 0)'])('ignores an unsupported user color', color => { + const attributes = CollaborationCaret.options.selectionRender({ name: 'Bob', color }) + + expect(attributes.style).toBeUndefined() + }) + test('applies a six-digit hex color to a selection', () => { const attributes = CollaborationCaret.options.selectionRender({ name: 'Bob', diff --git a/packages/extension-collaboration-caret/src/collaboration-caret.ts b/packages/extension-collaboration-caret/src/collaboration-caret.ts index 1826505198..1e3b60be49 100644 --- a/packages/extension-collaboration-caret/src/collaboration-caret.ts +++ b/packages/extension-collaboration-caret/src/collaboration-caret.ts @@ -1,8 +1,7 @@ -import { Extension } from '@tiptap/core' +import { Extension, isValidCSSStyleValue } from '@tiptap/core' import { Plugin, PluginKey } from '@tiptap/pm/state' import type { DecorationAttrs } from '@tiptap/pm/view' import { defaultSelectionBuilder, yCursorPlugin } from '@tiptap/y-tiptap' -import { isValidColor } from './lib/isValidColor.js' type CollaborationCaretStorage = { users: { clientId: number; [key: string]: any }[] @@ -99,9 +98,13 @@ const awarenessStatesToArray = (states: Map | null | const defaultOnUpdate = () => null +const isValidUserColor = (color: unknown): color is string => { + return isValidCSSStyleValue(color) && /^#[0-9a-fA-F]{6}$/.test(color) +} + const sanitizeUserColor = (user: Record) => ({ ...user, - color: isValidColor(user.color) ? user.color : 'transparent', + color: isValidUserColor(user.color) ? user.color : 'transparent', }) /** @@ -140,7 +143,7 @@ export const CollaborationCaret = Extension.create< return cursor }, selectionRender: user => { - if (!isValidColor(user.color)) { + if (!isValidUserColor(user.color)) { return {} } diff --git a/packages/extension-collaboration-caret/src/lib/isValidColor.test.ts b/packages/extension-collaboration-caret/src/lib/isValidColor.test.ts deleted file mode 100644 index 2709795fc0..0000000000 --- a/packages/extension-collaboration-caret/src/lib/isValidColor.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, test } from 'vite-plus/test' - -import { isValidColor } from './isValidColor.js' - -const maliciousColor = - 'red; position:fixed; top:0; left:0; width:100vw; height:100vh; z-index:2147483647; background:black' - -describe('isValidColor', () => { - // we only expect 6-digit hex values because thats - // what y-tiptap is exposing to the client - test.each(['#ffffff', '#1a2b3c', '#ABCDEF'])('returns true for a six-digit hex color', color => { - expect(isValidColor(color)).toBe(true) - }) - - test.each([ - null, - undefined, - '#fff', - '#ffff', - '#ffffffff', - 'red', - 'rgb(255, 0, 0)', - maliciousColor, - 'red; background-image: url(https://example.com)', - 'red } body { display: none', - ])('returns false for an unsupported color', color => { - expect(isValidColor(color)).toBe(false) - }) -}) diff --git a/packages/extension-collaboration-caret/src/lib/isValidColor.ts b/packages/extension-collaboration-caret/src/lib/isValidColor.ts deleted file mode 100644 index 664a582ea8..0000000000 --- a/packages/extension-collaboration-caret/src/lib/isValidColor.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function isValidColor(color: unknown): color is string { - return typeof color === 'string' && /^#[0-9a-fA-F]{6}$/.test(color) -} diff --git a/packages/extension-highlight/src/highlight.test.ts b/packages/extension-highlight/src/highlight.test.ts new file mode 100644 index 0000000000..c305d9ccd4 --- /dev/null +++ b/packages/extension-highlight/src/highlight.test.ts @@ -0,0 +1,44 @@ +import { generateHTML, type JSONContent } from '@tiptap/core' +import Document from '@tiptap/extension-document' +import Paragraph from '@tiptap/extension-paragraph' +import Text from '@tiptap/extension-text' +import { describe, expect, test } from 'vite-plus/test' + +import { Highlight } from './highlight.js' + +const renderHighlight = (color: string) => { + const content: JSONContent = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Example', + marks: [{ type: 'highlight', attrs: { color } }], + }, + ], + }, + ], + } + + return generateHTML(content, [ + Document, + Paragraph, + Text, + Highlight.configure({ multicolor: true }), + ]) +} + +describe('multicolor highlight rendering', () => { + test('renders a color from JSON', () => { + expect(renderHighlight('#ffcc00')).toBe( + '

Example

', + ) + }) + + test('does not render extra declarations from JSON', () => { + expect(renderHighlight('red; position: fixed')).toBe('

Example

') + }) +}) diff --git a/packages/extension-highlight/src/highlight.ts b/packages/extension-highlight/src/highlight.ts index f8a28ef744..c6839a5363 100644 --- a/packages/extension-highlight/src/highlight.ts +++ b/packages/extension-highlight/src/highlight.ts @@ -1,4 +1,11 @@ -import { getStyleProperty, Mark, markInputRule, markPasteRule, mergeAttributes } from '@tiptap/core' +import { + getStyleProperty, + isValidCSSStyleValue, + Mark, + markInputRule, + markPasteRule, + mergeAttributes, +} from '@tiptap/core' export interface HighlightOptions { /** @@ -81,7 +88,7 @@ export const Highlight = Mark.create({ getStyleProperty(element, 'background-color') || element.style.backgroundColor, renderHTML: attributes => { - if (!attributes.color) { + if (!isValidCSSStyleValue(attributes.color)) { return {} } diff --git a/packages/extension-table/src/utils/parseAlign.ts b/packages/extension-table/src/utils/parseAlign.ts index eea5d923ec..d887ac956f 100644 --- a/packages/extension-table/src/utils/parseAlign.ts +++ b/packages/extension-table/src/utils/parseAlign.ts @@ -1,4 +1,4 @@ -import type { Attribute } from '@tiptap/core' +import { type Attribute, isValidCSSStyleValue } from '@tiptap/core' /** * Supported table cell alignment values @@ -36,7 +36,7 @@ export function normalizeTableCellAlign(value: unknown): TableCellAlign | null { * @param element - The table cell/header DOM element * @returns A valid TableCellAlign value or null */ -export function parseAlign(element: HTMLElement): TableCellAlign | null { +function parseAlign(element: HTMLElement): TableCellAlign | null { const styleAlign = (element.style.textAlign || '').trim().toLowerCase() const attrAlign = (element.getAttribute('align') || '').trim().toLowerCase() const align = styleAlign || attrAlign @@ -65,13 +65,15 @@ export function createAlignAttribute(): Attribute { return { default: null, parseHTML: (element: HTMLElement) => parseAlign(element), - renderHTML: (attributes: { align?: TableCellAlign | null }) => { - if (!attributes.align) { + renderHTML: (attributes: { align?: unknown }) => { + const align = normalizeTableCellAlign(attributes.align) + + if (!isValidCSSStyleValue(align)) { return {} } return { - style: `text-align: ${attributes.align}`, + style: `text-align: ${align}`, } }, } diff --git a/packages/extension-table/tests/integration/tableCellAlignValidation.test.ts b/packages/extension-table/tests/integration/tableCellAlignValidation.test.ts new file mode 100644 index 0000000000..55602217bc --- /dev/null +++ b/packages/extension-table/tests/integration/tableCellAlignValidation.test.ts @@ -0,0 +1,57 @@ +import { generateHTML, type JSONContent } from '@tiptap/core' +import Document from '@tiptap/extension-document' +import Paragraph from '@tiptap/extension-paragraph' +import Text from '@tiptap/extension-text' +import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table' +import { describe, expect, test } from 'vite-plus/test' + +const extensions = [Document, Paragraph, Text, Table, TableRow, TableCell, TableHeader] + +const renderCell = (type: 'tableCell' | 'tableHeader', align: string) => { + const content: JSONContent = { + type: 'doc', + content: [ + { + type: 'table', + content: [ + { + type: 'tableRow', + content: [ + { + type, + attrs: { align }, + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Example' }], + }, + ], + }, + ], + }, + ], + }, + ], + } + + return generateHTML(content, extensions) +} + +describe('table cell alignment rendering', () => { + test.each(['tableCell', 'tableHeader'] satisfies Array<'tableCell' | 'tableHeader'>)( + 'renders an allowed alignment on %s JSON', + type => { + expect(renderCell(type, 'center')).toContain('style="text-align: center;"') + }, + ) + + test.each(['tableCell', 'tableHeader'] satisfies Array<'tableCell' | 'tableHeader'>)( + 'does not render extra declarations from %s JSON', + type => { + const html = renderCell(type, 'left; position: fixed') + + expect(html).not.toContain('text-align') + expect(html).not.toContain('position') + }, + ) +}) diff --git a/packages/extension-text-align/src/text-align.test.ts b/packages/extension-text-align/src/text-align.test.ts new file mode 100644 index 0000000000..7e9bc28b35 --- /dev/null +++ b/packages/extension-text-align/src/text-align.test.ts @@ -0,0 +1,41 @@ +import { generateHTML, type JSONContent } from '@tiptap/core' +import Document from '@tiptap/extension-document' +import Paragraph from '@tiptap/extension-paragraph' +import Text from '@tiptap/extension-text' +import { describe, expect, test } from 'vite-plus/test' + +import { TextAlign } from './text-align.js' + +const renderParagraph = (textAlign: string) => { + const content: JSONContent = { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { textAlign }, + content: [{ type: 'text', text: 'Example' }], + }, + ], + } + + return generateHTML(content, [ + Document, + Paragraph, + Text, + TextAlign.configure({ types: ['paragraph'] }), + ]) +} + +describe('text align rendering', () => { + test('renders an allowed alignment from JSON', () => { + expect(renderParagraph('center')).toBe('

Example

') + }) + + test('does not render extra declarations from JSON', () => { + expect(renderParagraph('left; position: fixed')).toBe('

Example

') + }) + + test('does not render an alignment outside the allowlist', () => { + expect(renderParagraph('start')).toBe('

Example

') + }) +}) diff --git a/packages/extension-text-align/src/text-align.ts b/packages/extension-text-align/src/text-align.ts index eaea875600..0b3c886a9e 100644 --- a/packages/extension-text-align/src/text-align.ts +++ b/packages/extension-text-align/src/text-align.ts @@ -1,4 +1,4 @@ -import { Extension } from '@tiptap/core' +import { Extension, isValidCSSStyleValue } from '@tiptap/core' export interface TextAlignOptions { /** @@ -77,7 +77,10 @@ export const TextAlign = Extension.create({ : this.options.defaultAlignment }, renderHTML: attributes => { - if (!attributes.textAlign) { + if ( + !isValidCSSStyleValue(attributes.textAlign) || + !this.options.alignments.includes(attributes.textAlign) + ) { return {} } diff --git a/packages/extension-text-style/src/background-color/background-color.ts b/packages/extension-text-style/src/background-color/background-color.ts index ee3e6a0a5d..c41fa72b8a 100644 --- a/packages/extension-text-style/src/background-color/background-color.ts +++ b/packages/extension-text-style/src/background-color/background-color.ts @@ -1,6 +1,6 @@ import '../text-style/index.js' -import { Extension, getStyleProperty } from '@tiptap/core' +import { Extension, getStyleProperty, isValidCSSStyleValue } from '@tiptap/core' export type BackgroundColorOptions = { /** @@ -66,7 +66,7 @@ export const BackgroundColor = Extension.create({ return value?.replace(/['"]+/g, '') }, renderHTML: attributes => { - if (!attributes.backgroundColor) { + if (!isValidCSSStyleValue(attributes.backgroundColor)) { return {} } diff --git a/packages/extension-text-style/src/color/color.ts b/packages/extension-text-style/src/color/color.ts index 907308a4df..b84b7c15c4 100644 --- a/packages/extension-text-style/src/color/color.ts +++ b/packages/extension-text-style/src/color/color.ts @@ -1,6 +1,6 @@ import '../text-style/index.js' -import { Extension, getStyleProperty } from '@tiptap/core' +import { Extension, getStyleProperty, isValidCSSStyleValue } from '@tiptap/core' export type ColorOptions = { /** @@ -65,7 +65,7 @@ export const Color = Extension.create({ return value?.replace(/['"]+/g, '') }, renderHTML: attributes => { - if (!attributes.color) { + if (!isValidCSSStyleValue(attributes.color)) { return {} } diff --git a/packages/extension-text-style/src/font-family/font-family.ts b/packages/extension-text-style/src/font-family/font-family.ts index a25d764752..47c982d96a 100644 --- a/packages/extension-text-style/src/font-family/font-family.ts +++ b/packages/extension-text-style/src/font-family/font-family.ts @@ -1,6 +1,6 @@ import '../text-style/index.js' -import { Extension, getStyleProperty } from '@tiptap/core' +import { Extension, getStyleProperty, isValidCSSStyleValue } from '@tiptap/core' export type FontFamilyOptions = { /** @@ -63,7 +63,7 @@ export const FontFamily = Extension.create({ parseHTML: element => getStyleProperty(element, 'font-family') ?? element.style.fontFamily, renderHTML: attributes => { - if (!attributes.fontFamily) { + if (!isValidCSSStyleValue(attributes.fontFamily)) { return {} } diff --git a/packages/extension-text-style/src/font-size/font-size.ts b/packages/extension-text-style/src/font-size/font-size.ts index 669202ad6e..d8d2aa789c 100644 --- a/packages/extension-text-style/src/font-size/font-size.ts +++ b/packages/extension-text-style/src/font-size/font-size.ts @@ -1,6 +1,6 @@ import '../text-style/index.js' -import { Extension, getStyleProperty } from '@tiptap/core' +import { Extension, getStyleProperty, isValidCSSStyleValue } from '@tiptap/core' export type FontSizeOptions = { /** @@ -61,7 +61,7 @@ export const FontSize = Extension.create({ // `element.style.fontSize`. parseHTML: element => getStyleProperty(element, 'font-size') ?? element.style.fontSize, renderHTML: attributes => { - if (!attributes.fontSize) { + if (!isValidCSSStyleValue(attributes.fontSize)) { return {} } diff --git a/packages/extension-text-style/src/line-height/line-height.ts b/packages/extension-text-style/src/line-height/line-height.ts index 7a304e2d48..29186f5e00 100644 --- a/packages/extension-text-style/src/line-height/line-height.ts +++ b/packages/extension-text-style/src/line-height/line-height.ts @@ -1,6 +1,6 @@ import '../text-style/index.js' -import { Extension, getStyleProperty } from '@tiptap/core' +import { Extension, getStyleProperty, isValidCSSStyleValue } from '@tiptap/core' export type LineHeightOptions = { /** @@ -62,7 +62,7 @@ export const LineHeight = Extension.create({ parseHTML: element => getStyleProperty(element, 'line-height') ?? element.style.lineHeight, renderHTML: attributes => { - if (!attributes.lineHeight) { + if (!isValidCSSStyleValue(attributes.lineHeight)) { return {} } diff --git a/packages/extension-text-style/tests/integration/styleAttributeValidation.test.ts b/packages/extension-text-style/tests/integration/styleAttributeValidation.test.ts new file mode 100644 index 0000000000..bde84932bf --- /dev/null +++ b/packages/extension-text-style/tests/integration/styleAttributeValidation.test.ts @@ -0,0 +1,59 @@ +import { generateHTML, type JSONContent } from '@tiptap/core' +import Document from '@tiptap/extension-document' +import Paragraph from '@tiptap/extension-paragraph' +import Text from '@tiptap/extension-text' +import { + BackgroundColor, + Color, + FontFamily, + FontSize, + LineHeight, + TextStyle, +} from '@tiptap/extension-text-style' +import { describe, expect, test } from 'vite-plus/test' + +const extensions = [ + Document, + Paragraph, + Text, + TextStyle, + BackgroundColor, + Color, + FontFamily, + FontSize, + LineHeight, +] + +const createContent = (attribute: string): JSONContent => ({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Example', + marks: [ + { + type: 'textStyle', + attrs: { + [attribute]: 'red; position: fixed', + }, + }, + ], + }, + ], + }, + ], +}) + +describe('text style attribute validation', () => { + test.each(['backgroundColor', 'color', 'fontFamily', 'fontSize', 'lineHeight'])( + 'does not render extra declarations from JSON in %s', + attribute => { + const html = generateHTML(createContent(attribute), extensions) + + expect(html).toBe('

Example

') + }, + ) +}) From c6cb2025d2d76f8cc277dd4a4547a45450bfb1c2 Mon Sep 17 00:00:00 2001 From: bdbch Date: Tue, 8 Sep 2026 09:43:14 +0200 Subject: [PATCH 2/2] fix: validate CSS style attribute values --- .changeset/2026-09-08-validate-css-style-values.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/2026-09-08-validate-css-style-values.md b/.changeset/2026-09-08-validate-css-style-values.md index 98dc37527a..8278884cf2 100644 --- a/.changeset/2026-09-08-validate-css-style-values.md +++ b/.changeset/2026-09-08-validate-css-style-values.md @@ -1,5 +1,6 @@ --- '@tiptap/core': patch +'@tiptap/extension-collaboration-caret': patch '@tiptap/extension-highlight': patch '@tiptap/extension-table': patch '@tiptap/extension-text-align': patch