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
160 changes: 160 additions & 0 deletions apps/realtime/src/handlers/presence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* @vitest-environment node
*/
import { ROOM_TYPES } from '@sim/realtime-protocol/rooms'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { setupPresenceHandlers } from '@/handlers/presence'
import type { IRoomManager } from '@/rooms'

const WORKFLOW_ROOM = { type: ROOM_TYPES.WORKFLOW, id: 'workflow-1' }

const SESSION = {
userId: 'user-1',
userName: 'Test User',
avatarUrl: 'avatar.png',
}

function createSocket() {
const handlers: Record<string, (payload: unknown) => Promise<void> | void> = {}
const toEmit = vi.fn()
const socket = {
id: 'socket-1',
on: vi.fn((event: string, handler: (payload: unknown) => Promise<void> | void) => {
handlers[event] = handler
}),
to: vi.fn().mockReturnValue({ emit: toEmit }),
}
return { handlers, socket, toEmit }
}

function createRoomManager(): IRoomManager {
return {
getRoomForSocket: vi.fn().mockResolvedValue(WORKFLOW_ROOM),
getUserSession: vi.fn().mockResolvedValue(SESSION),
updateUserActivity: vi.fn().mockResolvedValue(undefined),
} as unknown as IRoomManager
}

describe('presence handlers', () => {
let handlers: Record<string, (payload: unknown) => Promise<void> | void>
let toEmit: ReturnType<typeof vi.fn>
let roomManager: IRoomManager

beforeEach(() => {
vi.clearAllMocks()
const created = createSocket()
handlers = created.handlers
toEmit = created.toEmit
roomManager = createRoomManager()
setupPresenceHandlers(created.socket as never, roomManager)
})

describe('cursor-update', () => {
it('stores and broadcasts a well-formed cursor', async () => {
await handlers['cursor-update']({ cursor: { x: 12.5, y: -3 } })

expect(roomManager.updateUserActivity).toHaveBeenCalledWith(WORKFLOW_ROOM, 'socket-1', {
cursor: { x: 12.5, y: -3 },
})
expect(toEmit).toHaveBeenCalledWith(
'cursor-update',
expect.objectContaining({ cursor: { x: 12.5, y: -3 } })
)
})

it('preserves a cleared cursor', async () => {
await handlers['cursor-update']({ cursor: null })

expect(roomManager.updateUserActivity).toHaveBeenCalledWith(WORKFLOW_ROOM, 'socket-1', {
cursor: null,
})
expect(toEmit).toHaveBeenCalledWith(
'cursor-update',
expect.objectContaining({ cursor: null })
)
})

it('strips unexpected keys instead of storing them', async () => {
await handlers['cursor-update']({
cursor: { x: 1, y: 2, pad: 'A'.repeat(100_000) },
})

expect(roomManager.updateUserActivity).toHaveBeenCalledWith(WORKFLOW_ROOM, 'socket-1', {
cursor: { x: 1, y: 2 },
})
const broadcast = toEmit.mock.calls[0][1] as { cursor: Record<string, unknown> }
expect(broadcast.cursor).toEqual({ x: 1, y: 2 })
expect(broadcast.cursor).not.toHaveProperty('pad')
})

it.each([
['an oversized string', 'A'.repeat(100_000)],
['a non-numeric x', { x: 'A'.repeat(100_000), y: 1 }],
['a missing y', { x: 1 }],
['NaN coordinates', { x: Number.NaN, y: Number.NaN }],
['Infinity coordinates', { x: Number.POSITIVE_INFINITY, y: 0 }],
['an array', [1, 2, 3]],
['undefined', undefined],
])('drops %s without storing or broadcasting it', async (_label, cursor) => {
await handlers['cursor-update']({ cursor })

expect(roomManager.updateUserActivity).not.toHaveBeenCalled()
expect(toEmit).not.toHaveBeenCalled()
})
})

describe('selection-update', () => {
it('stores and broadcasts a well-formed selection', async () => {
await handlers['selection-update']({ selection: { type: 'block', id: 'block-1' } })

expect(roomManager.updateUserActivity).toHaveBeenCalledWith(WORKFLOW_ROOM, 'socket-1', {
selection: { type: 'block', id: 'block-1' },
})
expect(toEmit).toHaveBeenCalledWith(
'selection-update',
expect.objectContaining({ selection: { type: 'block', id: 'block-1' } })
)
})

it('keeps an id-less selection id-less', async () => {
await handlers['selection-update']({ selection: { type: 'none' } })

expect(roomManager.updateUserActivity).toHaveBeenCalledWith(WORKFLOW_ROOM, 'socket-1', {
selection: { type: 'none' },
})
})

it('strips unexpected keys instead of storing them', async () => {
await handlers['selection-update']({
selection: { type: 'edge', id: 'edge-1', pad: 'A'.repeat(100_000) },
})

expect(roomManager.updateUserActivity).toHaveBeenCalledWith(WORKFLOW_ROOM, 'socket-1', {
selection: { type: 'edge', id: 'edge-1' },
})
})

it.each([
['an unknown type', { type: 'evil', id: 'x' }],
['a missing type', { id: 'x' }],
['an oversized id', { type: 'block', id: 'A'.repeat(100_000) }],
['a non-string id', { type: 'block', id: { nested: 'A'.repeat(100_000) } }],
['null', null],
['an oversized string', 'A'.repeat(100_000)],
])('drops %s without storing or broadcasting it', async (_label, selection) => {
await handlers['selection-update']({ selection })

expect(roomManager.updateUserActivity).not.toHaveBeenCalled()
expect(toEmit).not.toHaveBeenCalled()
})
})

it('does not touch room state when the socket has no room', async () => {
;(roomManager.getRoomForSocket as ReturnType<typeof vi.fn>).mockResolvedValue(null)

await handlers['cursor-update']({ cursor: { x: 1, y: 1 } })

expect(roomManager.updateUserActivity).not.toHaveBeenCalled()
expect(toEmit).not.toHaveBeenCalled()
})
})
59 changes: 55 additions & 4 deletions apps/realtime/src/handlers/presence.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,66 @@
import { createLogger } from '@sim/logger'
import type { CursorPosition, PresenceSelection } from '@sim/realtime-protocol/events'
import { ROOM_TYPES } from '@sim/realtime-protocol/rooms'
import type { AuthenticatedSocket } from '@/middleware/auth'
import type { IRoomManager } from '@/rooms'

const logger = createLogger('PresenceHandlers')

/** Longest accepted selection id — real ids are UUIDs/short ids; this bounds a hostile payload. */
const MAX_SELECTION_ID_LENGTH = 200

/** The selection kinds a client may publish, mirroring {@link PresenceSelection}. */
const SELECTION_TYPES = new Set<PresenceSelection['type']>(['block', 'edge', 'none'])

/**
* Validate + whitelist an untrusted peer's cursor before it is stored and rebroadcast.
* Returns the normalized position — `null` for a legitimately cleared cursor — or
* `undefined` for anything malformed, so the caller drops it. Only `x`/`y` survive, so a
* hostile client can't amplify an oversized object through the room or the presence record.
*/
function normalizeCursor(cursor: unknown): CursorPosition | null | undefined {
if (cursor === null) return null
if (typeof cursor !== 'object') return undefined
const candidate = cursor as { x?: unknown; y?: unknown }
if (!Number.isFinite(candidate.x) || !Number.isFinite(candidate.y)) return undefined
return { x: candidate.x as number, y: candidate.y as number }
}

/**
* Validate + whitelist an untrusted peer's selection before it is stored and rebroadcast.
* Returns the normalized selection, or `undefined` for anything malformed, so the caller
* drops it. A cleared selection is expressed as `type: 'none'`, not `null`. Rebuilding from
* a fixed field set means unexpected keys can't ride along into the shared presence record.
*/
function normalizeSelection(selection: unknown): PresenceSelection | undefined {
if (typeof selection !== 'object' || selection === null) return undefined
const candidate = selection as { type?: unknown; id?: unknown }
if (!SELECTION_TYPES.has(candidate.type as PresenceSelection['type'])) return undefined
if (
candidate.id !== undefined &&
(typeof candidate.id !== 'string' || candidate.id.length > MAX_SELECTION_ID_LENGTH)
) {
return undefined
}
return {
type: candidate.type as PresenceSelection['type'],
...(typeof candidate.id === 'string' ? { id: candidate.id } : {}),
}
}

export function setupPresenceHandlers(socket: AuthenticatedSocket, roomManager: IRoomManager) {
socket.on('cursor-update', async ({ cursor }) => {
socket.on('cursor-update', async ({ cursor: rawCursor }: { cursor: unknown }) => {
try {
// Drop a malformed/oversized cursor from an untrusted peer before it is stored or
// rebroadcast (`undefined` = invalid; `null` = a legitimately cleared cursor).
const cursor = normalizeCursor(rawCursor)
if (cursor === undefined) return

const room = await roomManager.getRoomForSocket(socket.id, ROOM_TYPES.WORKFLOW)
const session = await roomManager.getUserSession(socket.id)

if (!room || !session) return

// Update cursor in room state
await roomManager.updateUserActivity(room, socket.id, { cursor })

// Broadcast to other users in the room (workflow room name is the bare id)
Expand All @@ -29,14 +76,18 @@ export function setupPresenceHandlers(socket: AuthenticatedSocket, roomManager:
}
})

socket.on('selection-update', async ({ selection }) => {
socket.on('selection-update', async ({ selection: rawSelection }: { selection: unknown }) => {
try {
// Drop a malformed/oversized selection from an untrusted peer before it is stored
// or rebroadcast.
const selection = normalizeSelection(rawSelection)
if (selection === undefined) return

const room = await roomManager.getRoomForSocket(socket.id, ROOM_TYPES.WORKFLOW)
const session = await roomManager.getUserSession(socket.id)

if (!room || !session) return

// Update selection in room state
await roomManager.updateUserActivity(room, socket.id, { selection })

// Broadcast to other users in the room (workflow room name is the bare id)
Expand Down
41 changes: 38 additions & 3 deletions apps/realtime/src/rooms/redis-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,41 @@ redis.call('EXPIRE', socketSessionKey, sessionTtl)
return 1
`

/**
* Ceiling on a single presence field, measured in the UTF-8 bytes Redis actually stores
* rather than UTF-16 code units, so a multi-byte payload can't pass a character-based check
* and still land several times larger in the room hash.
*
* The largest legitimate payload is a table cell selection: four ids capped at 200
* characters each. Multi-byte characters and JSON escaping can expand those well past
* their character count, so the realistic worst case approaches 5 KB — this sits comfortably
* above that, and a backstop that could trim real presence would be worse than a loose one.
* It bounds what one socket can park in the shared room hash and fan out to every peer when
* a presence-bearing event's handler validation is missing or regresses.
*/
const MAX_PRESENCE_FIELD_BYTES = 16384

/**
* Serialize one presence field for the activity script. Returns `''` when there is no
* update (the script skips the field) and, defensively, when the value exceeds
* {@link MAX_PRESENCE_FIELD_BYTES} — dropping just that field rather than the whole
* update, so a single oversized field can't suppress the others or the activity refresh.
*/
function serializePresenceField(
field: 'cursor' | 'selection' | 'cell',
value: unknown,
socketId: string
): string {
if (value === undefined) return ''
const serialized = JSON.stringify(value)
const bytes = Buffer.byteLength(serialized, 'utf8')
if (bytes > MAX_PRESENCE_FIELD_BYTES) {
logger.warn('Dropping oversized presence field', { field, socketId, bytes })
return ''
}
return serialized
}

/**
* Redis-backed room manager for multi-pod deployments. Domain-neutral: keyed by
* {@link RoomRef}, supports a socket in multiple rooms (one per {@link RoomType}).
Expand Down Expand Up @@ -370,14 +405,14 @@ export class RedisRoomManager implements IRoomManager {
keys: [KEYS.roomUsers(room), KEYS.socketRooms(socketId), KEYS.socketSession(socketId)],
arguments: [
socketId,
updates.cursor !== undefined ? JSON.stringify(updates.cursor) : '',
updates.selection !== undefined ? JSON.stringify(updates.selection) : '',
serializePresenceField('cursor', updates.cursor, socketId),
serializePresenceField('selection', updates.selection, socketId),
(updates.lastActivity ?? Date.now()).toString(),
SOCKET_ROOMS_TTL.toString(),
SESSION_TTL.toString(),
// Trailing arg (ARGV[7]) so existing indices stay stable. `null` (cleared
// selection) serializes to 'null'; `undefined` (no cell change) to '' (skip).
updates.cell !== undefined ? JSON.stringify(updates.cell) : '',
serializePresenceField('cell', updates.cell, socketId),
],
})
} catch (error) {
Expand Down
3 changes: 2 additions & 1 deletion apps/realtime/src/rooms/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ export interface UserPresence {
joinedAt: number
lastActivity: number
role: string
cursor?: { x: number; y: number }
/** The viewer's pointer position. `null` clears it (the pointer left the canvas). */
cursor?: { x: number; y: number } | null
selection?: { type: 'block' | 'edge' | 'none'; id?: string }
/** The viewer's current table cell selection, for table presence rooms. */
cell?: TableCellSelection
Expand Down
Loading