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
75 changes: 71 additions & 4 deletions apps/sim/app/_shell/paste-admission-guard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ import { PasteAdmissionGuard } from '@/app/_shell/paste-admission-guard'
let host: HTMLDivElement
let root: Root

function dispatchPaste(target: Element, text: string, selectionContext?: string): Event {
function dispatchPaste(
target: Element,
text: string,
options: { selectionContext?: string; html?: string; imageFile?: boolean } = {}
): Event {
const event = new Event('paste', {
bubbles: true,
cancelable: true,
Expand All @@ -27,9 +31,12 @@ function dispatchPaste(target: Element, text: string, selectionContext?: string)
value: {
getData: (type: string) => {
if (type === 'text/plain') return text
if (type === SIM_SELECTION_MIME) return selectionContext ?? ''
if (type === SIM_SELECTION_MIME) return options.selectionContext ?? ''
if (type === 'text/html') return options.html ?? ''
return ''
},
files: options.imageFile ? [new File(['image'], 'pasted.png', { type: 'image/png' })] : [],
items: options.imageFile ? [{ kind: 'file', type: 'image/png' }] : [],
},
})
target.dispatchEvent(event)
Expand Down Expand Up @@ -93,7 +100,36 @@ describe('PasteAdmissionGuard', () => {
expect(dispatchPaste(editable, 'a').defaultPrevented).toBe(false)
})

it('lets a compact Sim selection reference bypass its large plain-text representation', () => {
it('defers text admission to an editor that projects its exact paste result', () => {
const editor = document.createElement('div')
editor.setAttribute('contenteditable', 'true')
editor.dataset.pasteMaxBytes = '4'
editor.dataset.pasteProjectsTextResult = 'true'
host.appendChild(editor)

const targetHandler = vi.fn()
editor.addEventListener('paste', targetHandler)
expect(dispatchPaste(editor, '12345').defaultPrevented).toBe(false)
expect(targetHandler).toHaveBeenCalledOnce()
})

it('lets a prompt consume a compact Sim selection reference before its large plain text', () => {
const input = document.createElement('textarea')
input.dataset.pasteMaxBytes = '4'
input.dataset.pasteSelectionContext = 'reference'
host.appendChild(input)
const selectionContext = JSON.stringify({
kind: 'table_selection',
tableId: 'table-1',
tableName: 'Large table',
rowIds: ['row-1'],
label: 'Large table (1 row)',
})

expect(dispatchPaste(input, '12345', { selectionContext }).defaultPrevented).toBe(false)
})

it('still bounds a Sim selection plain-text representation outside the prompt', () => {
const input = document.createElement('textarea')
input.dataset.pasteMaxBytes = '4'
host.appendChild(input)
Expand All @@ -105,6 +141,37 @@ describe('PasteAdmissionGuard', () => {
label: 'Large table (1 row)',
})

expect(dispatchPaste(input, '12345', selectionContext).defaultPrevented).toBe(false)
expect(dispatchPaste(input, '12345', { selectionContext }).defaultPrevented).toBe(true)
})

it('bounds rich HTML separately from its smaller plain-text representation', () => {
const editable = document.createElement('div')
editable.setAttribute('contenteditable', 'true')
editable.dataset.pasteMaxBytes = '100'
editable.dataset.pasteMaxHtmlBytes = '10'
host.appendChild(editable)

expect(dispatchPaste(editable, 'abc', { html: '<strong>abc</strong>' }).defaultPrevented).toBe(
true
)
})

it('lets an opted-in rich editor handle clipboard image files before text admission', () => {
const editable = document.createElement('div')
editable.setAttribute('contenteditable', 'true')
editable.dataset.pasteMaxBytes = '4'
editable.dataset.pasteMaxHtmlBytes = '4'
editable.dataset.pasteHandlesImages = 'true'
host.appendChild(editable)

const targetHandler = vi.fn()
editable.addEventListener('paste', targetHandler)
const event = dispatchPaste(editable, '12345', {
html: '<img src="data:image/png;base64,large">',
imageFile: true,
})

expect(event.defaultPrevented).toBe(false)
expect(targetHandler).toHaveBeenCalledOnce()
})
})
55 changes: 43 additions & 12 deletions apps/sim/app/_shell/paste-admission-guard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,20 @@ function finitePositiveAttribute(element: Element | null, name: string): number
return Number.isFinite(value) && value > 0 ? value : undefined
}

function clipboardHasImageFile(data: DataTransfer | null): boolean {
if (!data) return false
if (Array.from(data.files).some((file) => file.type.startsWith('image/'))) return true
return Array.from(data.items).some(
(item) => item.kind === 'file' && item.type.startsWith('image/')
)
}

/**
* Last-resort admission for every editable workspace surface. Specialized editors publish their
* downstream ceiling on an ancestor with `data-paste-max-bytes`; controls without one inherit a
* crash-only fallback. This layer bounds only the clipboard payload, so a small paste into an already
* large field keeps native behavior. Editors with a real result-size contract enforce it themselves.
* Targets that explicitly handle clipboard images may claim those file events before the text guards.
* The capture listener runs before React, ProseMirror, Monaco, and xterm parse the clipboard value.
*/
export function PasteAdmissionGuard() {
Expand All @@ -33,31 +42,53 @@ export function PasteAdmissionGuard() {
return
}

if (readSelectionContextFromClipboard(event.clipboardData)) return
const acceptsSelectionContext = event.target.closest('[data-paste-selection-context]')
if (acceptsSelectionContext && readSelectionContextFromClipboard(event.clipboardData)) return

const text = event.clipboardData?.getData('text/plain') ?? ''
if (!text) return
const handlesImageFiles = event.target.closest('[data-paste-handles-images="true"]')
if (handlesImageFiles && clipboardHasImageFile(event.clipboardData)) return

const text = event.clipboardData?.getData('text/plain') ?? ''
const projectsTextResult = event.target.closest('[data-paste-projects-text-result="true"]')
const policyElement = event.target.closest('[data-paste-max-bytes]')
const maxPastedBytes =
finitePositiveAttribute(policyElement, 'data-paste-max-bytes') ?? PASTE_LIMITS.DEFAULT_BYTES
const maxPastedCharacters = finitePositiveAttribute(
policyElement,
'data-paste-max-characters'
)
const admission = assessTextPaste({
pastedText: text,
maxPastedBytes,
maxPastedCharacters,
})
if (admission.accepted) return
const textAdmission =
text && !projectsTextResult
? assessTextPaste({
pastedText: text,
maxPastedBytes,
maxPastedCharacters,
})
: null
const htmlPolicyElement = event.target.closest('[data-paste-max-html-bytes]')
const maxPastedHtmlBytes = finitePositiveAttribute(
htmlPolicyElement,
'data-paste-max-html-bytes'
)
const html = maxPastedHtmlBytes ? (event.clipboardData?.getData('text/html') ?? '') : ''
const htmlAdmission =
html && maxPastedHtmlBytes
? assessTextPaste({ pastedText: html, maxPastedBytes: maxPastedHtmlBytes })
: null
const rejection =
textAdmission && !textAdmission.accepted
? textAdmission
: htmlAdmission && !htmlAdmission.accepted
? htmlAdmission
: null
if (!rejection) return

event.preventDefault()
event.stopImmediatePropagation()
const limit =
admission.reason === 'pasted-characters'
? `${admission.limit.toLocaleString()} characters`
: formatPasteLimit(admission.limit)
rejection.reason === 'pasted-characters'
? `${rejection.limit.toLocaleString()} characters`
: formatPasteLimit(rejection.limit)
notifyRef.current.warning('Paste is too large for this editor', {
description: `The clipboard content was left unchanged. This editor supports up to ${limit}.`,
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@
import { Editor } from '@tiptap/core'
import { TextSelection } from '@tiptap/pm/state'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createMarkdownContentExtensions } from './extensions'
import { createRichMarkdownPasteAdmission } from './paste-admission'
import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions'
import {
assessRawMarkdownPaste,
createRichMarkdownPasteAdmission,
} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission'

let editor: Editor | null = null

Expand All @@ -14,10 +17,16 @@ afterEach(() => {
editor = null
})

function runPaste(ed: Editor, text: string): { handled: boolean; prevented: boolean } {
function runPaste(ed: Editor, text: string, html = ''): { handled: boolean; prevented: boolean } {
let prevented = false
const event = {
clipboardData: { getData: (type: string) => (type === 'text/plain' ? text : '') },
clipboardData: {
getData: (type: string) => {
if (type === 'text/plain') return text
if (type === 'text/html') return html
return ''
},
},
preventDefault: () => {
prevented = true
},
Expand All @@ -31,6 +40,20 @@ function runPaste(ed: Editor, text: string): { handled: boolean; prevented: bool
}

describe('rich Markdown paste admission', () => {
it('rejects a raw-text append whose projected result exceeds the limit', () => {
expect(
assessRawMarkdownPaste(
{
pastedText: '56789',
currentText: '123456',
selectionStart: 6,
selectionEnd: 6,
},
10
)
).toEqual({ accepted: false, reason: 'result-bytes', actual: 11, limit: 10 })
})

it('rejects before downstream paste parsing when projected bytes exceed the document limit', () => {
const onRejected = vi.fn()
editor = new Editor({
Expand Down Expand Up @@ -88,4 +111,51 @@ describe('rich Markdown paste admission', () => {

expect(runPaste(editor, '1234567890')).toEqual({ handled: false, prevented: false })
})

it('rejects oversized rich HTML before downstream parsing', () => {
const onRejected = vi.fn()
editor = new Editor({
extensions: [
...createMarkdownContentExtensions(),
createRichMarkdownPasteAdmission({
maxResultBytes: 10,
getCurrentText: () => '',
onRejected,
}),
],
content: '<p></p>',
})

expect(runPaste(editor, 'x', '<strong>abc</strong>')).toEqual({
handled: true,
prevented: true,
})
expect(onRejected).toHaveBeenCalledOnce()
})

it('rejects a paste whose canonical Markdown result exceeds the limit', () => {
const onRejected = vi.fn()
editor = new Editor({
extensions: [
...createMarkdownContentExtensions(),
createRichMarkdownPasteAdmission({
maxResultBytes: 10,
getCurrentText: () => '123456',
onRejected,
}),
],
content: '<p>123456</p>',
})
const strong = editor.schema.marks.bold.create()
const transaction = editor.state.tr
.replaceSelectionWith(editor.schema.text('abc', [strong]), false)
.setMeta('uiEvent', 'paste')

expect(editor.markdown.serialize(transaction.doc.toJSON())).toBe('**abc**123456')
expect(transaction.getMeta('uiEvent')).toBe('paste')
editor.view.dispatch(transaction)

expect(editor.getText()).toBe('123456')
expect(onRejected).toHaveBeenCalledOnce()
})
})
Loading
Loading