mirror of
https://github.com/stablyai/orca.git
synced 2026-10-04 08:02:09 +00:00
fix(native-chat): trust only authenticated local image uploads
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user