Skip to content

Commit c6477b8

Browse files
fix(ui): tighten clipboard prefix matching to prevent sibling row leakage on copy/paste (#17595)
Backport of #16742 to `3.x`. ### What? `mergeFormStateFromClipboard` and its helper `reduceFormStateByPath` used `key.startsWith(prefix)` at three callsites without enforcing a path boundary, leaking unrelated form-state keys into the clipboard on copy and into the merge target on paste. Most visible failure mode: array fields with 10 or more rows. Copying row 1 bundled rows 10, 11, 12 into the clipboard (their paths all start with `children.1`). On paste, those leaked rows got rewritten to out-of-bounds indices like `.50`, `.51`, `.52`, surfacing as phantom rows the editor could not remove, failing validation and blocking save. The same loose match also let textual sibling fields cross-contaminate (`children` vs `childrenOther`). ### Why? `'children.1'.startsWith('children.1')` matches `'children.10'`, `'children.11'`, etc. `'children'.startsWith('children')` matches `'childrenOther'`. ### How? Added a small private helper requiring the candidate path to either equal the prefix or be followed by `.`: ```ts function isStrictPathPrefix(key: string, prefix: string): boolean { return key === prefix || key.startsWith(`${prefix}.`) } ``` Replaced `startsWith` at all three callsites. No behavior change for non-colliding paths. ### Tests Includes the `describe('prefix collision with multi-digit sibling indices')` block covering the reduce/merge/cleanup paths described above. Fixes #16741 --------- Co-authored-by: Julie <julie@bitcoin.com>
1 parent 57278bd commit c6477b8

2 files changed

Lines changed: 234 additions & 5 deletions

File tree

packages/ui/src/elements/ClipboardAction/mergeFormStateFromClipboard.spec.ts

Lines changed: 222 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import type { FormState } from 'payload'
33
import ObjectIdImport from 'bson-objectid'
44
import { describe, expect, it } from 'vitest'
55

6-
import { mergeFormStateFromClipboard } from './mergeFormStateFromClipboard.js'
6+
import {
7+
mergeFormStateFromClipboard,
8+
reduceFormStateByPath,
9+
} from './mergeFormStateFromClipboard.js'
710
import type { ClipboardPasteData } from './types.js'
811

912
const ObjectId = (
@@ -549,4 +552,222 @@ describe('mergeFormStateFromClipboard', () => {
549552
expect(result['disableSort.0.text'].value).toEqual('row one')
550553
})
551554
})
555+
556+
describe('prefix collision with multi-digit sibling indices', () => {
557+
it('reduceFormStateByPath should not leak siblings whose indices share a digit prefix', () => {
558+
// Array with 13 rows. Copying row 1 must filter out rows 10/11/12, whose
559+
// paths all begin with the substring `children.1`.
560+
const formState: FormState = {
561+
children: {
562+
valid: true,
563+
value: 13,
564+
rows: Array.from({ length: 13 }, () => ({ isLoading: false })),
565+
},
566+
'children.1.id': { value: 'id-1', valid: true },
567+
'children.1.title': { value: 'Row 1', valid: true },
568+
'children.10.id': { value: 'id-10', valid: true },
569+
'children.10.title': { value: 'Row 10', valid: true },
570+
'children.11.id': { value: 'id-11', valid: true },
571+
'children.11.title': { value: 'Row 11', valid: true },
572+
'children.12.id': { value: 'id-12', valid: true },
573+
'children.12.title': { value: 'Row 12', valid: true },
574+
}
575+
576+
const filtered = reduceFormStateByPath({
577+
formState,
578+
path: 'children',
579+
rowIndex: 1,
580+
})
581+
582+
const filteredKeys = Object.keys(filtered).sort()
583+
expect(filteredKeys).toEqual(['children.1.id', 'children.1.title'])
584+
expect(filtered['children.10.id']).toBeUndefined()
585+
expect(filtered['children.11.id']).toBeUndefined()
586+
expect(filtered['children.12.id']).toBeUndefined()
587+
})
588+
589+
it('reduceFormStateByPath should not leak field-name siblings sharing a textual prefix', () => {
590+
// `children` vs `childrenOther`: loose prefix matching pulls the wrong field's state.
591+
const formState: FormState = {
592+
children: { valid: true, value: 1, rows: [{ isLoading: false }] },
593+
'children.0.id': { value: 'children-row', valid: true },
594+
childrenOther: { valid: true, value: 1, rows: [{ isLoading: false }] },
595+
'childrenOther.0.id': { value: 'other-row', valid: true },
596+
}
597+
598+
const filtered = reduceFormStateByPath({ formState, path: 'children' })
599+
600+
// The field's own key carries `rows` and `value`, so it must survive the filter.
601+
expect(filtered.children).toBeDefined()
602+
expect(filtered['children.0.id']).toBeDefined()
603+
expect(filtered.childrenOther).toBeUndefined()
604+
expect(filtered['childrenOther.0.id']).toBeUndefined()
605+
})
606+
607+
it('paste should not create out-of-bounds rows from a leaked clipboard payload', () => {
608+
// Simulates the end-to-end Copy/Paste flow against an array with 13 rows. Even if a
609+
// pre-fix clipboard leaked sibling rows (.10/.11/.12) when copying row 1, the merge
610+
// filter must drop them rather than rewriting them to .50/.51/.52 when pasting into
611+
// row 5.
612+
const formState: FormState = {
613+
children: {
614+
valid: true,
615+
value: 13,
616+
rows: Array.from({ length: 13 }, () => ({
617+
id: new ObjectId().toHexString(),
618+
isLoading: false,
619+
})),
620+
},
621+
'children.5.id': { value: new ObjectId().toHexString(), valid: true },
622+
}
623+
624+
const sourceID = new ObjectId().toHexString()
625+
const clipboardData: ClipboardPasteData = {
626+
type: 'array',
627+
path: 'children',
628+
fields: [],
629+
rowIndex: 1,
630+
data: {
631+
'children.1.id': { value: sourceID, valid: true },
632+
'children.1.title': { value: 'Row 1 title', valid: true },
633+
// Leaked siblings — these must be dropped, not rewritten to .50/.51/.52.
634+
'children.10.id': { value: new ObjectId().toHexString(), valid: true },
635+
'children.10.title': { value: 'Leaked Row 10', valid: true },
636+
'children.11.id': { value: new ObjectId().toHexString(), valid: true },
637+
'children.11.title': { value: 'Leaked Row 11', valid: true },
638+
'children.12.id': { value: new ObjectId().toHexString(), valid: true },
639+
'children.12.title': { value: 'Leaked Row 12', valid: true },
640+
},
641+
}
642+
643+
const result = mergeFormStateFromClipboard({
644+
dataFromClipboard: clipboardData,
645+
formState,
646+
path: 'children',
647+
rowIndex: 5,
648+
})
649+
650+
// Target row populated from the source row.
651+
expect(result['children.5.title']?.value).toEqual('Row 1 title')
652+
653+
// No phantom out-of-bounds rows. These would correspond to `.10`/`.11`/`.12` being
654+
// rewritten through `String.replace('children.1', 'children.5')`.
655+
expect(result['children.50']).toBeUndefined()
656+
expect(result['children.51']).toBeUndefined()
657+
expect(result['children.52']).toBeUndefined()
658+
expect(result['children.50.id']).toBeUndefined()
659+
expect(result['children.51.id']).toBeUndefined()
660+
expect(result['children.52.id']).toBeUndefined()
661+
expect(result['children.50.title']).toBeUndefined()
662+
expect(result['children.51.title']).toBeUndefined()
663+
expect(result['children.52.title']).toBeUndefined()
664+
})
665+
666+
it('row-to-field cleanup should not delete unrelated fields with a textual prefix collision', () => {
667+
// `path` = 'children', `lastRenderedPath` = 'children.0'. A sibling field
668+
// `childrenOther.0.title` must NOT be deleted by the cleanup loop.
669+
const formState: FormState = {
670+
children: { valid: true, value: 0, rows: [{ isLoading: false }] },
671+
'children.0.id': { value: new ObjectId().toHexString(), valid: true },
672+
childrenOther: { valid: true, value: 1, rows: [{ isLoading: false }] },
673+
'childrenOther.0.id': { value: new ObjectId().toHexString(), valid: true },
674+
'childrenOther.0.title': { value: 'Unrelated field', valid: true },
675+
}
676+
677+
const sourceID = new ObjectId().toHexString()
678+
const clipboardData: ClipboardPasteData = {
679+
type: 'array',
680+
path: 'someOtherArray',
681+
fields: [],
682+
rowIndex: 0,
683+
data: {
684+
'someOtherArray.0.id': { value: sourceID, valid: true },
685+
'someOtherArray.0.title': { value: 'Pasted row', valid: true },
686+
},
687+
}
688+
689+
const result = mergeFormStateFromClipboard({
690+
dataFromClipboard: clipboardData,
691+
formState,
692+
path: 'children',
693+
})
694+
695+
// Unrelated `childrenOther.*` paths survive the cleanup.
696+
expect(result.childrenOther).toBeDefined()
697+
expect(result['childrenOther.0.id']).toBeDefined()
698+
expect(result['childrenOther.0.title']?.value).toEqual('Unrelated field')
699+
})
700+
})
701+
702+
describe('exact path matches', () => {
703+
// Blocks store the block type under the row key itself (`ctas.1`), with no trailing
704+
// segment. Tightening the prefix check must keep matching that exact key, otherwise
705+
// copy drops the block type and paste never restores it.
706+
it('reduceFormStateByPath should keep the exact row key while filtering digit-prefix siblings', () => {
707+
const formState: FormState = {
708+
ctas: {
709+
valid: true,
710+
value: 13,
711+
rows: Array.from({ length: 13 }, () => ({
712+
blockType: 'callToAction',
713+
isLoading: false,
714+
})),
715+
},
716+
'ctas.1': { value: 'callToAction', valid: true },
717+
'ctas.1.id': { value: 'id-1', valid: true },
718+
'ctas.10': { value: 'callToAction', valid: true },
719+
'ctas.10.id': { value: 'id-10', valid: true },
720+
'ctas.11': { value: 'callToAction', valid: true },
721+
'ctas.11.id': { value: 'id-11', valid: true },
722+
}
723+
724+
const filtered = reduceFormStateByPath({ formState, path: 'ctas', rowIndex: 1 })
725+
726+
const filteredKeys = Object.keys(filtered).sort()
727+
expect(filteredKeys).toEqual(['ctas.1', 'ctas.1.id'])
728+
expect(filtered['ctas.1']?.value).toEqual('callToAction')
729+
})
730+
731+
it('paste should copy the exact row key across to the target row', () => {
732+
const sourceBlockID = new ObjectId().toHexString()
733+
const targetBlockID = new ObjectId().toHexString()
734+
735+
const formState: FormState = {
736+
ctas: {
737+
valid: true,
738+
value: 2,
739+
initialValue: 2,
740+
rows: [
741+
{ id: sourceBlockID, blockType: 'callToAction', isLoading: false },
742+
{ id: targetBlockID, blockType: 'content', isLoading: false },
743+
],
744+
},
745+
'ctas.1': { value: 'content', valid: true },
746+
'ctas.1.id': { value: targetBlockID, valid: true },
747+
}
748+
749+
const clipboardData: ClipboardPasteData = {
750+
type: 'blocks',
751+
path: 'ctas',
752+
blocks: [],
753+
rowIndex: 0,
754+
data: {
755+
'ctas.0': { value: 'callToAction', valid: true },
756+
'ctas.0.id': { value: sourceBlockID, valid: true },
757+
'ctas.0.label': { value: 'Source label', valid: true },
758+
},
759+
}
760+
761+
const result = mergeFormStateFromClipboard({
762+
dataFromClipboard: clipboardData,
763+
formState,
764+
path: 'ctas',
765+
rowIndex: 1,
766+
})
767+
768+
// The block type lives at the exact row key, so pasting must overwrite it.
769+
expect(result['ctas.1']?.value).toEqual('callToAction')
770+
expect(result['ctas.1.label']?.value).toEqual('Source label')
771+
})
772+
})
552773
})

packages/ui/src/elements/ClipboardAction/mergeFormStateFromClipboard.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ import type { ClipboardPasteData } from './types.js'
66

77
const ObjectId = 'default' in ObjectIdImport ? ObjectIdImport.default : ObjectIdImport
88

9+
// Strict path-prefix check: matches `prefix` exactly, or any descendant path (`prefix.`).
10+
// Required because numeric row indices share digit prefixes (`.1` vs `.10`/`.11`), and
11+
// field names can also share textual prefixes (`children` vs `childrenOther`). A loose
12+
// `startsWith(prefix)` leaks unrelated keys into clipboard / cleanup operations.
13+
function isStrictPathPrefix(key: string, prefix: string): boolean {
14+
return key === prefix || key.startsWith(`${prefix}.`)
15+
}
16+
917
export function reduceFormStateByPath({
1018
formState,
1119
path,
@@ -19,7 +27,7 @@ export function reduceFormStateByPath({
1927
const prefix = typeof rowIndex !== 'number' ? path : `${path}.${rowIndex}`
2028

2129
for (const key in formState) {
22-
if (!key.startsWith(prefix)) {
30+
if (!isStrictPathPrefix(key, prefix)) {
2331
continue
2432
}
2533

@@ -102,8 +110,8 @@ export function mergeFormStateFromClipboard({
102110
for (const fieldPath in formState) {
103111
if (
104112
fieldPath !== path &&
105-
!fieldPath.startsWith(lastRenderedPath) &&
106-
fieldPath.startsWith(path)
113+
!isStrictPathPrefix(fieldPath, lastRenderedPath) &&
114+
isStrictPathPrefix(fieldPath, path)
107115
) {
108116
delete formState[fieldPath]
109117
}
@@ -119,7 +127,7 @@ export function mergeFormStateFromClipboard({
119127
// still be processed so they get regenerated below, preventing server-side duplication.
120128
if (
121129
(!pasteIntoField && clipboardPath === `${pathToReplace}.id`) ||
122-
!clipboardPath.startsWith(pathToReplace)
130+
!isStrictPathPrefix(clipboardPath, pathToReplace)
123131
) {
124132
continue
125133
}

0 commit comments

Comments
 (0)