fix(native-chat): trust only authenticated local image uploads

This commit is contained in:
Brennan Benson
2026-08-26 10:54:12 -07:00
parent 290b4b3b4a
commit 3d1cc588fb
2 changed files with 191 additions and 13 deletions
+129 -1
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
import type { RpcRequest, RpcResponse } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
import {
CLIPBOARD_IMAGE_MAX_BASE64_CHARS,
@@ -21,6 +21,10 @@ import {
CLIPBOARD_METHODS,
resetClipboardImageUploadsForTest
} from './clipboard'
import {
hasMobileClipboardImagePath,
resetMobileClipboardImageProvenanceForTest
} from '../mobile-clipboard-image-provenance'
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
@@ -31,15 +35,36 @@ function makeDispatcher(): RpcDispatcher {
return new RpcDispatcher({ runtime, methods: CLIPBOARD_METHODS })
}
async function callMobile(
dispatcher: RpcDispatcher,
method: string,
params: unknown,
clientId = 'device-a'
): Promise<RpcResponse> {
const replies: RpcResponse[] = []
await dispatcher.dispatchStreaming(
makeRequest(method, params),
(raw) => replies.push(JSON.parse(raw) as RpcResponse),
{ clientKind: 'mobile', clientId }
)
const response = replies[0]
if (!response) {
throw new Error(`no reply for ${method}`)
}
return response
}
describe('clipboard RPC methods', () => {
beforeEach(() => {
saveClipboardImageBufferAsTempFile.mockReset()
resetClipboardImageUploadsForTest()
resetMobileClipboardImageProvenanceForTest()
})
afterEach(() => {
vi.useRealTimers()
resetClipboardImageUploadsForTest()
resetMobileClipboardImageProvenanceForTest()
})
it('saves browser-provided clipboard image bytes on the runtime host', async () => {
@@ -64,6 +89,67 @@ describe('clipboard RPC methods', () => {
})
})
it('records a successful direct mobile upload for only the authenticated client', async () => {
const path = '/tmp/orca-paste-image.png'
saveClipboardImageBufferAsTempFile.mockResolvedValue(path)
const dispatcher = makeDispatcher()
await expect(
callMobile(dispatcher, 'clipboard.saveImageAsTempFile', {
contentBase64: Buffer.from('png-bytes').toString('base64'),
connectionId: null
})
).resolves.toMatchObject({ ok: true, result: path })
expect(hasMobileClipboardImagePath('device-a', path)).toBe(true)
expect(hasMobileClipboardImagePath('device-b', path)).toBe(false)
})
it('does not authorize a remote-host clipboard path for local structured delivery', async () => {
const path = '/tmp/orca-paste-image.png'
saveClipboardImageBufferAsTempFile.mockResolvedValue(path)
const dispatcher = makeDispatcher()
await expect(
callMobile(dispatcher, 'clipboard.saveImageAsTempFile', {
contentBase64: Buffer.from('png-bytes').toString('base64'),
connectionId: 'ssh-1'
})
).resolves.toMatchObject({ ok: true, result: path })
expect(hasMobileClipboardImagePath('device-a', path)).toBe(false)
})
it('requires authenticated mobile identity before saving a direct upload', async () => {
const dispatcher = makeDispatcher()
const response = await dispatcher.dispatch(
makeRequest('clipboard.saveImageAsTempFile', {
contentBase64: Buffer.from('png-bytes').toString('base64'),
connectionId: null
}),
{ clientKind: 'mobile' }
)
expect(response).toMatchObject({ ok: false })
expect(saveClipboardImageBufferAsTempFile).not.toHaveBeenCalled()
})
it('does not record provenance when a direct upload fails to save', async () => {
const path = '/tmp/orca-paste-image.png'
saveClipboardImageBufferAsTempFile.mockRejectedValue(new Error('disk full'))
const dispatcher = makeDispatcher()
await expect(
callMobile(dispatcher, 'clipboard.saveImageAsTempFile', {
contentBase64: Buffer.from('png-bytes').toString('base64'),
connectionId: null
})
).resolves.toMatchObject({ ok: false })
expect(hasMobileClipboardImagePath('device-a', path)).toBe(false)
})
it('rejects non-base64 clipboard image payloads', async () => {
const dispatcher = makeDispatcher()
@@ -140,6 +226,48 @@ describe('clipboard RPC methods', () => {
expect(saveClipboardImageBufferAsTempFile).toHaveBeenCalledWith(Buffer.from('png-bytes'), {
connectionId: 'ssh-1'
})
expect(hasMobileClipboardImagePath('device-a', '/tmp/orca-paste-image.png')).toBe(false)
})
it('binds chunk mutation and provenance to the mobile client that started the upload', async () => {
saveClipboardImageBufferAsTempFile.mockResolvedValue('/tmp/orca-paste-image.png')
const dispatcher = makeDispatcher()
const contentBase64 = Buffer.from('png-bytes').toString('base64')
const start = await callMobile(dispatcher, 'clipboard.startImageUpload', {
expectedBase64Length: contentBase64.length,
connectionId: null
})
const uploadId = (start.ok ? start.result : null) as { uploadId: string }
for (const method of [
'clipboard.appendImageUploadChunk',
'clipboard.commitImageUpload',
'clipboard.abortImageUpload'
]) {
const params =
method === 'clipboard.appendImageUploadChunk'
? { uploadId: uploadId.uploadId, offset: 0, contentBase64 }
: { uploadId: uploadId.uploadId }
await expect(callMobile(dispatcher, method, params, 'device-b')).resolves.toMatchObject({
ok: false
})
}
await expect(
callMobile(dispatcher, 'clipboard.appendImageUploadChunk', {
uploadId: uploadId.uploadId,
offset: 0,
contentBase64
})
).resolves.toMatchObject({
ok: true,
result: { receivedBase64Length: contentBase64.length }
})
await expect(
callMobile(dispatcher, 'clipboard.commitImageUpload', { uploadId: uploadId.uploadId })
).resolves.toMatchObject({ ok: true, result: '/tmp/orca-paste-image.png' })
expect(hasMobileClipboardImagePath('device-a', '/tmp/orca-paste-image.png')).toBe(true)
expect(hasMobileClipboardImagePath('device-b', '/tmp/orca-paste-image.png')).toBe(false)
})
it('rejects out-of-order chunk offsets', async () => {
+62 -12
View File
@@ -1,11 +1,12 @@
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { defineMethod, type RpcContext, type RpcMethod } from '../core'
import { saveClipboardImageBufferAsTempFile } from '../../../window/clipboard-image-temp-file'
import { randomUUID } from 'node:crypto'
import {
CLIPBOARD_IMAGE_MAX_BASE64_CHARS,
CLIPBOARD_IMAGE_TOO_LARGE_ERROR
} from '../../../../shared/clipboard-image'
import { recordMobileClipboardImagePath } from '../mobile-clipboard-image-provenance'
const MAX_CLIPBOARD_IMAGE_BASE64_CHARS = CLIPBOARD_IMAGE_MAX_BASE64_CHARS
export const CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = 512 * 1024
@@ -16,6 +17,7 @@ const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/
type ClipboardImageUpload = {
expectedBase64Length: number
connectionId?: string | null
mobileClientId?: string
chunks: string[]
receivedBase64Length: number
expiresAt: number
@@ -69,6 +71,30 @@ function getUpload(uploadId: string): ClipboardImageUpload {
return upload
}
function mobileClientId(ctx: RpcContext): string | undefined {
if (ctx.clientKind !== 'mobile') {
return undefined
}
const clientId = ctx.clientId?.trim()
if (!clientId) {
throw new Error('Clipboard image upload requires an authenticated mobile client')
}
return clientId
}
function assertMobileUploadOwner(
upload: ClipboardImageUpload,
ctx: RpcContext
): string | undefined {
const clientId = mobileClientId(ctx)
if (clientId && upload.mobileClientId !== clientId) {
// Don't reveal whether another client owns the upload or whether its id
// exists at all.
throw new Error('Clipboard image upload was not found')
}
return clientId
}
function assertValidBase64Content(value: string): void {
if (!isValidBase64(value)) {
throw new Error('Clipboard image content must be base64')
@@ -131,15 +157,24 @@ export const CLIPBOARD_METHODS: RpcMethod[] = [
defineMethod({
name: 'clipboard.saveImageAsTempFile',
params: SaveImageAsTempFile,
handler: async (params) =>
saveClipboardImageBufferAsTempFile(Buffer.from(params.contentBase64, 'base64'), {
connectionId: params.connectionId
})
handler: async (params, ctx) => {
const clientId = mobileClientId(ctx)
const path = await saveClipboardImageBufferAsTempFile(
Buffer.from(params.contentBase64, 'base64'),
{
connectionId: params.connectionId
}
)
if (clientId && !params.connectionId) {
recordMobileClipboardImagePath(clientId, path)
}
return path
}
}),
defineMethod({
name: 'clipboard.startImageUpload',
params: StartImageUpload,
handler: (params) => {
handler: (params, ctx) => {
pruneExpiredUploads()
if (clipboardImageUploads.size >= CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT) {
throw new Error('Too many clipboard image uploads are in progress')
@@ -148,6 +183,7 @@ export const CLIPBOARD_METHODS: RpcMethod[] = [
clipboardImageUploads.set(uploadId, {
expectedBase64Length: params.expectedBase64Length,
connectionId: params.connectionId,
mobileClientId: mobileClientId(ctx),
chunks: [],
receivedBase64Length: 0,
expiresAt: Date.now() + CLIPBOARD_IMAGE_UPLOAD_TTL_MS,
@@ -159,8 +195,9 @@ export const CLIPBOARD_METHODS: RpcMethod[] = [
defineMethod({
name: 'clipboard.appendImageUploadChunk',
params: AppendImageUploadChunk,
handler: (params) => {
handler: (params, ctx) => {
const upload = getUpload(params.uploadId)
assertMobileUploadOwner(upload, ctx)
if (params.offset !== upload.receivedBase64Length) {
throw new Error('Clipboard image chunk offset is out of order')
}
@@ -177,17 +214,25 @@ export const CLIPBOARD_METHODS: RpcMethod[] = [
defineMethod({
name: 'clipboard.commitImageUpload',
params: CommitImageUpload,
handler: async (params) => {
handler: async (params, ctx) => {
const upload = getUpload(params.uploadId)
const clientId = assertMobileUploadOwner(upload, ctx)
try {
if (upload.receivedBase64Length !== upload.expectedBase64Length) {
throw new Error('Clipboard image upload is incomplete')
}
const contentBase64 = upload.chunks.join('')
assertValidBase64Content(contentBase64)
return await saveClipboardImageBufferAsTempFile(Buffer.from(contentBase64, 'base64'), {
connectionId: upload.connectionId
})
const path = await saveClipboardImageBufferAsTempFile(
Buffer.from(contentBase64, 'base64'),
{
connectionId: upload.connectionId
}
)
if (clientId && !upload.connectionId) {
recordMobileClipboardImagePath(clientId, path)
}
return path
} finally {
// Why: failed SSH or filesystem commits must not leave bounded upload
// memory pinned until TTL cleanup.
@@ -198,7 +243,12 @@ export const CLIPBOARD_METHODS: RpcMethod[] = [
defineMethod({
name: 'clipboard.abortImageUpload',
params: AbortImageUpload,
handler: (params) => {
handler: (params, ctx) => {
pruneExpiredUploads()
const upload = clipboardImageUploads.get(params.uploadId)
if (upload) {
assertMobileUploadOwner(upload, ctx)
}
deleteUpload(params.uploadId)
return { aborted: true }
}