Skip to content

Commit 30fee83

Browse files
fix(ui): encode HTML special characters in version diff view (#15747)
Adds proper HTML encoding for field values displayed in the version comparison diff view. Special characters like <, >, &, ", and ' are now correctly rendered as literal text instead of being interpreted as HTML markup. - Add escapeDiffHTML and unescapeDiffHTML utilities to HTMLDiff - Use Unicode placeholders during diffing to preserve diff accuracy - Apply encoding to Text, Select, and Date diff components - Add tests for text and JSON fields containing special characters
1 parent 2baea2e commit 30fee83

File tree

10 files changed

+216
-26
lines changed

10 files changed

+216
-26
lines changed

packages/next/src/views/Version/RenderFieldsToDiff/fields/Date/index.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,18 @@
22
import type { DateFieldDiffClientComponent } from 'payload'
33

44
import {
5+
escapeDiffHTML,
56
FieldDiffContainer,
67
getHTMLDiffComponents,
8+
unescapeDiffHTML,
79
useConfig,
810
useTranslation,
911
} from '@payloadcms/ui'
1012
import { formatDate } from '@payloadcms/ui/shared'
13+
import React from 'react'
1114

1215
import './index.scss'
1316

14-
import React from 'react'
15-
1617
const baseClass = 'date-diff'
1718

1819
export const DateDiffComponent: DateFieldDiffClientComponent = ({
@@ -45,14 +46,18 @@ export const DateDiffComponent: DateFieldDiffClientComponent = ({
4546
})
4647
: ''
4748

49+
const escapedFromDate = escapeDiffHTML(formattedFromDate)
50+
const escapedToDate = escapeDiffHTML(formattedToDate)
51+
4852
const { From, To } = getHTMLDiffComponents({
4953
fromHTML:
50-
`<div class="${baseClass}" data-enable-match="true" data-date="${formattedFromDate}"><p>` +
51-
formattedFromDate +
54+
`<div class="${baseClass}" data-enable-match="true" data-date="${escapedFromDate}"><p>` +
55+
escapedFromDate +
5256
'</p></div>',
57+
postProcess: unescapeDiffHTML,
5358
toHTML:
54-
`<div class="${baseClass}" data-enable-match="true" data-date="${formattedToDate}"><p>` +
55-
formattedToDate +
59+
`<div class="${baseClass}" data-enable-match="true" data-date="${escapedToDate}"><p>` +
60+
escapedToDate +
5661
'</p></div>',
5762
tokenizeByCharacter: false,
5863
})

packages/next/src/views/Version/RenderFieldsToDiff/fields/Select/index.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import type { I18nClient } from '@payloadcms/translations'
33
import type { Option, SelectField, SelectFieldDiffClientComponent } from 'payload'
44

55
import { getTranslation } from '@payloadcms/translations'
6-
import { FieldDiffContainer, getHTMLDiffComponents, useTranslation } from '@payloadcms/ui'
6+
import {
7+
escapeDiffHTML,
8+
FieldDiffContainer,
9+
getHTMLDiffComponents,
10+
unescapeDiffHTML,
11+
useTranslation,
12+
} from '@payloadcms/ui'
713
import React from 'react'
814

915
import './index.scss'
@@ -94,8 +100,9 @@ export const Select: SelectFieldDiffClientComponent = ({
94100
: ''
95101

96102
const { From, To } = getHTMLDiffComponents({
97-
fromHTML: '<p>' + renderedValueFrom + '</p>',
98-
toHTML: '<p>' + renderedValueTo + '</p>',
103+
fromHTML: '<p>' + escapeDiffHTML(renderedValueFrom) + '</p>',
104+
postProcess: unescapeDiffHTML,
105+
toHTML: '<p>' + escapeDiffHTML(renderedValueTo) + '</p>',
99106
tokenizeByCharacter: true,
100107
})
101108

packages/next/src/views/Version/RenderFieldsToDiff/fields/Text/index.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,25 @@
11
'use client'
22
import type { TextFieldDiffClientComponent } from 'payload'
33

4-
import { FieldDiffContainer, getHTMLDiffComponents, useTranslation } from '@payloadcms/ui'
4+
import {
5+
escapeDiffHTML,
6+
FieldDiffContainer,
7+
getHTMLDiffComponents,
8+
unescapeDiffHTML,
9+
useTranslation,
10+
} from '@payloadcms/ui'
11+
import React from 'react'
512

613
import './index.scss'
714

8-
import React from 'react'
9-
1015
const baseClass = 'text-diff'
1116

1217
function formatValue(value: unknown): {
1318
tokenizeByCharacter: boolean
1419
value: string
1520
} {
1621
if (typeof value === 'string') {
17-
return { tokenizeByCharacter: true, value }
22+
return { tokenizeByCharacter: true, value: escapeDiffHTML(value) }
1823
}
1924
if (typeof value === 'number') {
2025
return {
@@ -32,7 +37,7 @@ function formatValue(value: unknown): {
3237
if (value && typeof value === 'object') {
3338
return {
3439
tokenizeByCharacter: false,
35-
value: `<pre>${JSON.stringify(value, null, 2)}</pre>`,
40+
value: `<pre>${escapeDiffHTML(JSON.stringify(value, null, 2))}</pre>`,
3641
}
3742
}
3843

@@ -72,6 +77,7 @@ export const Text: TextFieldDiffClientComponent = ({
7277

7378
const { From, To } = getHTMLDiffComponents({
7479
fromHTML: '<p>' + renderedValueFrom + '</p>',
80+
postProcess: unescapeDiffHTML,
7581
toHTML: '<p>' + renderedValueTo + '</p>',
7682
tokenizeByCharacter,
7783
})
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Unicode Private Use Area characters for temporary escaping during diff.
2+
// These are replaced with HTML entities after the diff is computed.
3+
const ESCAPE_MAP = {
4+
'"': '\uE004',
5+
'&': '\uE001',
6+
"'": '\uE005',
7+
'<': '\uE002',
8+
'>': '\uE003',
9+
} as const
10+
11+
const UNESCAPE_MAP = {
12+
'\uE001': '&amp;',
13+
'\uE002': '&lt;',
14+
'\uE003': '&gt;',
15+
'\uE004': '&quot;',
16+
'\uE005': '&#39;',
17+
} as const
18+
19+
/**
20+
* Escapes HTML special characters using Unicode placeholders.
21+
* These must be converted to HTML entities after diffing using unescapeDiffHTML.
22+
*/
23+
export function escapeDiffHTML(value: boolean | null | number | string | undefined): string {
24+
if (value == null) {
25+
return ''
26+
}
27+
28+
const str = typeof value === 'string' ? value : String(value)
29+
30+
return str
31+
.replace(/&/g, ESCAPE_MAP['&'])
32+
.replace(/</g, ESCAPE_MAP['<'])
33+
.replace(/>/g, ESCAPE_MAP['>'])
34+
.replace(/"/g, ESCAPE_MAP['"'])
35+
.replace(/'/g, ESCAPE_MAP["'"])
36+
}
37+
38+
/**
39+
* Converts Unicode placeholder characters to HTML entities.
40+
* Call this on the final HTML output after diffing.
41+
*/
42+
export function unescapeDiffHTML(html: string): string {
43+
return html
44+
.replace(/\uE001/g, UNESCAPE_MAP['\uE001'])
45+
.replace(/\uE002/g, UNESCAPE_MAP['\uE002'])
46+
.replace(/\uE003/g, UNESCAPE_MAP['\uE003'])
47+
.replace(/\uE004/g, UNESCAPE_MAP['\uE004'])
48+
.replace(/\uE005/g, UNESCAPE_MAP['\uE005'])
49+
}

packages/ui/src/elements/HTMLDiff/index.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,22 @@ import React from 'react'
33
import { HtmlDiff } from './diff/index.js'
44
import './index.scss'
55

6+
export { escapeDiffHTML, unescapeDiffHTML } from './escapeHtml.js'
7+
68
const baseClass = 'html-diff'
79

810
export const getHTMLDiffComponents = ({
911
fromHTML,
12+
postProcess,
1013
toHTML,
1114
tokenizeByCharacter,
1215
}: {
1316
fromHTML: string
17+
/**
18+
* Optional function to transform the HTML output after diffing.
19+
* Useful for converting escape sequences to HTML entities.
20+
*/
21+
postProcess?: (html: string) => string
1422
toHTML: string
1523
tokenizeByCharacter?: boolean
1624
}): {
@@ -21,7 +29,12 @@ export const getHTMLDiffComponents = ({
2129
tokenizeByCharacter,
2230
})
2331

24-
const [oldHTML, newHTML] = diffHTML.getSideBySideContents()
32+
let [oldHTML, newHTML] = diffHTML.getSideBySideContents()
33+
34+
if (postProcess) {
35+
oldHTML = postProcess(oldHTML)
36+
newHTML = postProcess(newHTML)
37+
}
2538

2639
const From = oldHTML ? (
2740
<div

packages/ui/src/exports/client/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,11 @@ export { DeleteMany } from '../../elements/DeleteMany/index.js'
7676
export { DocumentControls } from '../../elements/DocumentControls/index.js'
7777
export { Dropzone } from '../../elements/Dropzone/index.js'
7878
export { documentDrawerBaseClass, useDocumentDrawer } from '../../elements/DocumentDrawer/index.js'
79-
export { getHTMLDiffComponents } from '../../elements/HTMLDiff/index.js'
79+
export {
80+
escapeDiffHTML,
81+
getHTMLDiffComponents,
82+
unescapeDiffHTML,
83+
} from '../../elements/HTMLDiff/index.js'
8084
export type {
8185
DocumentDrawerProps,
8286
DocumentTogglerProps,

packages/ui/src/exports/rsc/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ export { FieldDiffContainer } from '../../elements/FieldDiffContainer/index.js'
22
export { FieldDiffLabel } from '../../elements/FieldDiffLabel/index.js'
33
export { FolderTableCell } from '../../elements/FolderView/Cell/index.server.js'
44
export { FolderField } from '../../elements/FolderView/FolderField/index.server.js'
5-
export { getHTMLDiffComponents } from '../../elements/HTMLDiff/index.js'
5+
export {
6+
escapeDiffHTML,
7+
getHTMLDiffComponents,
8+
unescapeDiffHTML,
9+
} from '../../elements/HTMLDiff/index.js'
610
export { _internal_renderFieldHandler } from '../../forms/fieldSchemasToFormState/serverFunctions/renderFieldServerFn.js'
711
export { File } from '../../graphics/File/index.js'
812
export { CheckIcon } from '../../icons/Check/index.js'

test/__helpers/e2e/navigateToDiffVersionView.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export async function navigateToDiffVersionView({
2121
/**
2222
* If not provided, will attempt to navigate to the latest version's diff view
2323
*/
24-
versionID?: string
24+
versionID?: number | string
2525
}) {
2626
if (versionID) {
2727
const versionURL = formatAdminURL({

test/versions/e2e.spec.ts

Lines changed: 108 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,12 @@
2323
*/
2424

2525
import type { BrowserContext, Dialog, Page } from '@playwright/test'
26+
import type { TypeWithID } from 'payload'
2627

2728
import { expect, test } from '@playwright/test'
28-
import { postsCollectionSlug } from 'admin/slugs.js'
2929
import { checkFocusIndicators } from '__helpers/e2e/checkFocusIndicators.js'
3030
import { runAxeScan } from '__helpers/e2e/runAxeScan.js'
31+
import { postsCollectionSlug } from 'admin/slugs.js'
3132
import mongoose from 'mongoose'
3233
import path from 'path'
3334
import { formatAdminURL, wait } from 'payload/shared'
@@ -36,6 +37,7 @@ import { fileURLToPath } from 'url'
3637
import type { PayloadTestSDK } from '../__helpers/shared/sdk/index.js'
3738
import type { Config, Diff } from './payload-types.js'
3839

40+
import { assertNetworkRequests } from '../__helpers/e2e/assertNetworkRequests.js'
3941
import {
4042
changeLocale,
4143
ensureCompilationIsDone,
@@ -47,13 +49,12 @@ import {
4749
waitForFormReady,
4850
// throttleTest,
4951
} from '../__helpers/e2e/helpers.js'
50-
import { AdminUrlUtil } from '../__helpers/shared/adminUrlUtil.js'
51-
import { assertNetworkRequests } from '../__helpers/e2e/assertNetworkRequests.js'
5252
import { navigateToDiffVersionView as _navigateToDiffVersionView } from '../__helpers/e2e/navigateToDiffVersionView.js'
5353
import { openDocControls } from '../__helpers/e2e/openDocControls.js'
5454
import { waitForAutoSaveToRunAndComplete } from '../__helpers/e2e/waitForAutoSaveToRunAndComplete.js'
55-
import { initPayloadE2ENoConfig } from '../__helpers/shared/initPayloadE2ENoConfig.js'
55+
import { AdminUrlUtil } from '../__helpers/shared/adminUrlUtil.js'
5656
import { reInitializeDB } from '../__helpers/shared/clearAndSeed/reInitializeDB.js'
57+
import { initPayloadE2ENoConfig } from '../__helpers/shared/initPayloadE2ENoConfig.js'
5758
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
5859
import { draftWithCustomUnpublishSlug } from './collections/DraftsWithCustomUnpublish.js'
5960
import { BASE_PATH } from './shared.js'
@@ -78,8 +79,8 @@ import {
7879
localizedCollectionSlug,
7980
localizedGlobalSlug,
8081
postCollectionSlug,
81-
versionCollectionSlug,
8282
textCollectionSlug,
83+
versionCollectionSlug,
8384
} from './slugs.js'
8485

8586
const filename = fileURLToPath(import.meta.url)
@@ -2677,6 +2678,108 @@ describe('Versions', () => {
26772678
const blocks = page.locator('[data-field-path="blocks"]')
26782679
await expect(blocks).toBeVisible()
26792680
})
2681+
2682+
test('correctly renders text fields containing HTML special characters', async () => {
2683+
// Create a document with HTML special characters in a text field
2684+
const doc = await payload.create({
2685+
collection: diffCollectionSlug,
2686+
data: {
2687+
_status: 'published',
2688+
text: '<b>bold</b> & "quotes"',
2689+
},
2690+
})
2691+
2692+
// Update to create a version
2693+
await payload.update({
2694+
collection: diffCollectionSlug,
2695+
id: doc.id,
2696+
data: {
2697+
_status: 'published',
2698+
text: '<script>alert(1)</script>',
2699+
},
2700+
})
2701+
2702+
const versionDiff = (
2703+
await payload.findVersions({
2704+
collection: diffCollectionSlug,
2705+
depth: 0,
2706+
limit: 1,
2707+
where: {
2708+
parent: { equals: doc.id },
2709+
},
2710+
})
2711+
).docs[0] as unknown as TypeWithID
2712+
2713+
await _navigateToDiffVersionView({
2714+
adminRoute,
2715+
serverURL,
2716+
collectionSlug: diffCollectionSlug,
2717+
docID: doc.id,
2718+
versionID: versionDiff.id,
2719+
page,
2720+
})
2721+
2722+
const text = page.locator('[data-field-path="text"]')
2723+
2724+
// Verify that HTML characters are rendered as literal text, not interpreted as HTML
2725+
await expect(text.locator('.html-diff__diff-old')).toHaveText('<b>bold</b> & "quotes"')
2726+
await expect(text.locator('.html-diff__diff-new')).toHaveText('<script>alert(1)</script>')
2727+
2728+
// Cleanup
2729+
await payload.delete({ collection: diffCollectionSlug, id: doc.id })
2730+
})
2731+
2732+
test('correctly renders JSON fields containing HTML special characters', async () => {
2733+
// Create a document with HTML special characters in a JSON field
2734+
const doc = await payload.create({
2735+
collection: diffCollectionSlug,
2736+
data: {
2737+
_status: 'published',
2738+
json: { html: '<div class="test">&amp;</div>' },
2739+
},
2740+
})
2741+
2742+
// Update to create a version
2743+
await payload.update({
2744+
collection: diffCollectionSlug,
2745+
id: doc.id,
2746+
data: {
2747+
_status: 'published',
2748+
json: { html: '<span onclick="alert(1)">click</span>' },
2749+
},
2750+
})
2751+
2752+
const versionDiff = (
2753+
await payload.findVersions({
2754+
collection: diffCollectionSlug,
2755+
depth: 0,
2756+
limit: 1,
2757+
where: {
2758+
parent: { equals: doc.id },
2759+
},
2760+
})
2761+
).docs[0] as unknown as TypeWithID
2762+
2763+
await _navigateToDiffVersionView({
2764+
adminRoute,
2765+
serverURL,
2766+
collectionSlug: diffCollectionSlug,
2767+
docID: doc.id,
2768+
versionID: versionDiff.id,
2769+
page,
2770+
})
2771+
2772+
const json = page.locator('[data-field-path="json"]')
2773+
2774+
// Verify that JSON with HTML characters renders the literal text
2775+
await expect(json.locator('.html-diff__diff-old')).toContainText('"<div class=\\"test\\">')
2776+
await expect(json.locator('.html-diff__diff-new')).toContainText(
2777+
'"<span onclick=\\"alert(1)\\">',
2778+
)
2779+
2780+
// Cleanup
2781+
await payload.delete({ collection: diffCollectionSlug, id: doc.id })
2782+
})
26802783
})
26812784

26822785
describe('Scheduled publish', () => {

0 commit comments

Comments
 (0)