Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .changeset/2026-09-08-validate-css-style-values.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@tiptap/core': patch
'@tiptap/extension-collaboration-caret': 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.
1 change: 1 addition & 0 deletions packages/core/src/utilities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
22 changes: 22 additions & 0 deletions packages/core/src/utilities/isValidCSSStyleValue.test.ts
Original file line number Diff line number Diff line change
@@ -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)
},
)
})
8 changes: 8 additions & 0 deletions packages/core/src/utilities/isValidCSSStyleValue.ts
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }[]
Expand Down Expand Up @@ -99,9 +98,13 @@ const awarenessStatesToArray = (states: Map<number, Record<string, any> | 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<string, any>) => ({
...user,
color: isValidColor(user.color) ? user.color : 'transparent',
color: isValidUserColor(user.color) ? user.color : 'transparent',
})

/**
Expand Down Expand Up @@ -140,7 +143,7 @@ export const CollaborationCaret = Extension.create<
return cursor
},
selectionRender: user => {
if (!isValidColor(user.color)) {
if (!isValidUserColor(user.color)) {
return {}
}

Expand Down

This file was deleted.

This file was deleted.

44 changes: 44 additions & 0 deletions packages/extension-highlight/src/highlight.test.ts
Original file line number Diff line number Diff line change
@@ -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(
'<p><mark data-color="#ffcc00" style="background-color: #ffcc00; color: inherit;">Example</mark></p>',
)
})

test('does not render extra declarations from JSON', () => {
expect(renderHighlight('red; position: fixed')).toBe('<p><mark>Example</mark></p>')
})
})
11 changes: 9 additions & 2 deletions packages/extension-highlight/src/highlight.ts
Original file line number Diff line number Diff line change
@@ -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 {
/**
Expand Down Expand Up @@ -81,7 +88,7 @@ export const Highlight = Mark.create<HighlightOptions>({
getStyleProperty(element, 'background-color') ||
element.style.backgroundColor,
renderHTML: attributes => {
if (!attributes.color) {
if (!isValidCSSStyleValue(attributes.color)) {
return {}
}

Expand Down
12 changes: 7 additions & 5 deletions packages/extension-table/src/utils/parseAlign.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Attribute } from '@tiptap/core'
import { type Attribute, isValidCSSStyleValue } from '@tiptap/core'

/**
* Supported table cell alignment values
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}`,
}
},
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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')
},
)
})
41 changes: 41 additions & 0 deletions packages/extension-text-align/src/text-align.test.ts
Original file line number Diff line number Diff line change
@@ -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('<p style="text-align: center;">Example</p>')
})

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

test('does not render an alignment outside the allowlist', () => {
expect(renderParagraph('start')).toBe('<p>Example</p>')
})
})
7 changes: 5 additions & 2 deletions packages/extension-text-align/src/text-align.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Extension } from '@tiptap/core'
import { Extension, isValidCSSStyleValue } from '@tiptap/core'

export interface TextAlignOptions {
/**
Expand Down Expand Up @@ -77,7 +77,10 @@ export const TextAlign = Extension.create<TextAlignOptions>({
: this.options.defaultAlignment
},
renderHTML: attributes => {
if (!attributes.textAlign) {
if (
!isValidCSSStyleValue(attributes.textAlign) ||
!this.options.alignments.includes(attributes.textAlign)
) {
return {}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 = {
/**
Expand Down Expand Up @@ -66,7 +66,7 @@ export const BackgroundColor = Extension.create<BackgroundColorOptions>({
return value?.replace(/['"]+/g, '')
},
renderHTML: attributes => {
if (!attributes.backgroundColor) {
if (!isValidCSSStyleValue(attributes.backgroundColor)) {
return {}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/extension-text-style/src/color/color.ts
Original file line number Diff line number Diff line change
@@ -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 = {
/**
Expand Down Expand Up @@ -65,7 +65,7 @@ export const Color = Extension.create<ColorOptions>({
return value?.replace(/['"]+/g, '')
},
renderHTML: attributes => {
if (!attributes.color) {
if (!isValidCSSStyleValue(attributes.color)) {
return {}
}

Expand Down
Loading
Loading