Skip to content

Commit cf80252

Browse files
committed
fix(realtime): validate cursor and selection presence payloads
The workflow cursor-update and selection-update handlers stored whatever object a client sent into the shared room presence hash and rebroadcast it to every peer, with no shape or size check. An authenticated user with read access to any workflow could park a multi-megabyte blob in shared Redis on every socket they opened and have the server fan it out on each presence broadcast. Both payloads are now rebuilt from a fixed field set before they reach room state or any broadcast, so unexpected keys cannot ride along - mirroring normalizeCellSelection in the table presence handler. Adds a defensive per-field length cap in updateUserActivity so future presence-bearing events inherit the bound, and marks UserPresence.cursor nullable to match the cleared-cursor value the client already sends.
1 parent 6b46f69 commit cf80252

4 files changed

Lines changed: 253 additions & 8 deletions

File tree

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { ROOM_TYPES } from '@sim/realtime-protocol/rooms'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
import { setupPresenceHandlers } from '@/handlers/presence'
7+
import type { IRoomManager } from '@/rooms'
8+
9+
const WORKFLOW_ROOM = { type: ROOM_TYPES.WORKFLOW, id: 'workflow-1' }
10+
11+
const SESSION = {
12+
userId: 'user-1',
13+
userName: 'Test User',
14+
avatarUrl: 'avatar.png',
15+
}
16+
17+
function createSocket() {
18+
const handlers: Record<string, (payload: unknown) => Promise<void> | void> = {}
19+
const toEmit = vi.fn()
20+
const socket = {
21+
id: 'socket-1',
22+
on: vi.fn((event: string, handler: (payload: unknown) => Promise<void> | void) => {
23+
handlers[event] = handler
24+
}),
25+
to: vi.fn().mockReturnValue({ emit: toEmit }),
26+
}
27+
return { handlers, socket, toEmit }
28+
}
29+
30+
function createRoomManager(): IRoomManager {
31+
return {
32+
getRoomForSocket: vi.fn().mockResolvedValue(WORKFLOW_ROOM),
33+
getUserSession: vi.fn().mockResolvedValue(SESSION),
34+
updateUserActivity: vi.fn().mockResolvedValue(undefined),
35+
} as unknown as IRoomManager
36+
}
37+
38+
describe('presence handlers', () => {
39+
let handlers: Record<string, (payload: unknown) => Promise<void> | void>
40+
let toEmit: ReturnType<typeof vi.fn>
41+
let roomManager: IRoomManager
42+
43+
beforeEach(() => {
44+
vi.clearAllMocks()
45+
const created = createSocket()
46+
handlers = created.handlers
47+
toEmit = created.toEmit
48+
roomManager = createRoomManager()
49+
setupPresenceHandlers(created.socket as never, roomManager)
50+
})
51+
52+
describe('cursor-update', () => {
53+
it('stores and broadcasts a well-formed cursor', async () => {
54+
await handlers['cursor-update']({ cursor: { x: 12.5, y: -3 } })
55+
56+
expect(roomManager.updateUserActivity).toHaveBeenCalledWith(WORKFLOW_ROOM, 'socket-1', {
57+
cursor: { x: 12.5, y: -3 },
58+
})
59+
expect(toEmit).toHaveBeenCalledWith(
60+
'cursor-update',
61+
expect.objectContaining({ cursor: { x: 12.5, y: -3 } })
62+
)
63+
})
64+
65+
it('preserves a cleared cursor', async () => {
66+
await handlers['cursor-update']({ cursor: null })
67+
68+
expect(roomManager.updateUserActivity).toHaveBeenCalledWith(WORKFLOW_ROOM, 'socket-1', {
69+
cursor: null,
70+
})
71+
expect(toEmit).toHaveBeenCalledWith(
72+
'cursor-update',
73+
expect.objectContaining({ cursor: null })
74+
)
75+
})
76+
77+
it('strips unexpected keys instead of storing them', async () => {
78+
await handlers['cursor-update']({
79+
cursor: { x: 1, y: 2, pad: 'A'.repeat(100_000) },
80+
})
81+
82+
expect(roomManager.updateUserActivity).toHaveBeenCalledWith(WORKFLOW_ROOM, 'socket-1', {
83+
cursor: { x: 1, y: 2 },
84+
})
85+
const broadcast = toEmit.mock.calls[0][1] as { cursor: Record<string, unknown> }
86+
expect(broadcast.cursor).toEqual({ x: 1, y: 2 })
87+
expect(broadcast.cursor).not.toHaveProperty('pad')
88+
})
89+
90+
it.each([
91+
['an oversized string', 'A'.repeat(100_000)],
92+
['a non-numeric x', { x: 'A'.repeat(100_000), y: 1 }],
93+
['a missing y', { x: 1 }],
94+
['NaN coordinates', { x: Number.NaN, y: Number.NaN }],
95+
['Infinity coordinates', { x: Number.POSITIVE_INFINITY, y: 0 }],
96+
['an array', [1, 2, 3]],
97+
['undefined', undefined],
98+
])('drops %s without storing or broadcasting it', async (_label, cursor) => {
99+
await handlers['cursor-update']({ cursor })
100+
101+
expect(roomManager.updateUserActivity).not.toHaveBeenCalled()
102+
expect(toEmit).not.toHaveBeenCalled()
103+
})
104+
})
105+
106+
describe('selection-update', () => {
107+
it('stores and broadcasts a well-formed selection', async () => {
108+
await handlers['selection-update']({ selection: { type: 'block', id: 'block-1' } })
109+
110+
expect(roomManager.updateUserActivity).toHaveBeenCalledWith(WORKFLOW_ROOM, 'socket-1', {
111+
selection: { type: 'block', id: 'block-1' },
112+
})
113+
expect(toEmit).toHaveBeenCalledWith(
114+
'selection-update',
115+
expect.objectContaining({ selection: { type: 'block', id: 'block-1' } })
116+
)
117+
})
118+
119+
it('keeps an id-less selection id-less', async () => {
120+
await handlers['selection-update']({ selection: { type: 'none' } })
121+
122+
expect(roomManager.updateUserActivity).toHaveBeenCalledWith(WORKFLOW_ROOM, 'socket-1', {
123+
selection: { type: 'none' },
124+
})
125+
})
126+
127+
it('strips unexpected keys instead of storing them', async () => {
128+
await handlers['selection-update']({
129+
selection: { type: 'edge', id: 'edge-1', pad: 'A'.repeat(100_000) },
130+
})
131+
132+
expect(roomManager.updateUserActivity).toHaveBeenCalledWith(WORKFLOW_ROOM, 'socket-1', {
133+
selection: { type: 'edge', id: 'edge-1' },
134+
})
135+
})
136+
137+
it.each([
138+
['an unknown type', { type: 'evil', id: 'x' }],
139+
['a missing type', { id: 'x' }],
140+
['an oversized id', { type: 'block', id: 'A'.repeat(100_000) }],
141+
['a non-string id', { type: 'block', id: { nested: 'A'.repeat(100_000) } }],
142+
['null', null],
143+
['an oversized string', 'A'.repeat(100_000)],
144+
])('drops %s without storing or broadcasting it', async (_label, selection) => {
145+
await handlers['selection-update']({ selection })
146+
147+
expect(roomManager.updateUserActivity).not.toHaveBeenCalled()
148+
expect(toEmit).not.toHaveBeenCalled()
149+
})
150+
})
151+
152+
it('does not touch room state when the socket has no room', async () => {
153+
;(roomManager.getRoomForSocket as ReturnType<typeof vi.fn>).mockResolvedValue(null)
154+
155+
await handlers['cursor-update']({ cursor: { x: 1, y: 1 } })
156+
157+
expect(roomManager.updateUserActivity).not.toHaveBeenCalled()
158+
expect(toEmit).not.toHaveBeenCalled()
159+
})
160+
})

‎apps/realtime/src/handlers/presence.ts‎

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,66 @@
11
import { createLogger } from '@sim/logger'
2+
import type { CursorPosition, PresenceSelection } from '@sim/realtime-protocol/events'
23
import { ROOM_TYPES } from '@sim/realtime-protocol/rooms'
34
import type { AuthenticatedSocket } from '@/middleware/auth'
45
import type { IRoomManager } from '@/rooms'
56

67
const logger = createLogger('PresenceHandlers')
78

9+
/** Longest accepted selection id — real ids are UUIDs/short ids; this bounds a hostile payload. */
10+
const MAX_SELECTION_ID_LENGTH = 200
11+
12+
/** The selection kinds a client may publish, mirroring {@link PresenceSelection}. */
13+
const SELECTION_TYPES = new Set<PresenceSelection['type']>(['block', 'edge', 'none'])
14+
15+
/**
16+
* Validate + whitelist an untrusted peer's cursor before it is stored and rebroadcast.
17+
* Returns the normalized position — `null` for a legitimately cleared cursor — or
18+
* `undefined` for anything malformed, so the caller drops it. Only `x`/`y` survive, so a
19+
* hostile client can't amplify an oversized object through the room or the presence record.
20+
*/
21+
function normalizeCursor(cursor: unknown): CursorPosition | null | undefined {
22+
if (cursor === null) return null
23+
if (typeof cursor !== 'object') return undefined
24+
const candidate = cursor as { x?: unknown; y?: unknown }
25+
if (!Number.isFinite(candidate.x) || !Number.isFinite(candidate.y)) return undefined
26+
return { x: candidate.x as number, y: candidate.y as number }
27+
}
28+
29+
/**
30+
* Validate + whitelist an untrusted peer's selection before it is stored and rebroadcast.
31+
* Returns the normalized selection, or `undefined` for anything malformed, so the caller
32+
* drops it. A cleared selection is expressed as `type: 'none'`, not `null`. Rebuilding from
33+
* a fixed field set means unexpected keys can't ride along into the shared presence record.
34+
*/
35+
function normalizeSelection(selection: unknown): PresenceSelection | undefined {
36+
if (typeof selection !== 'object' || selection === null) return undefined
37+
const candidate = selection as { type?: unknown; id?: unknown }
38+
if (!SELECTION_TYPES.has(candidate.type as PresenceSelection['type'])) return undefined
39+
if (
40+
candidate.id !== undefined &&
41+
(typeof candidate.id !== 'string' || candidate.id.length > MAX_SELECTION_ID_LENGTH)
42+
) {
43+
return undefined
44+
}
45+
return {
46+
type: candidate.type as PresenceSelection['type'],
47+
...(typeof candidate.id === 'string' ? { id: candidate.id } : {}),
48+
}
49+
}
50+
851
export function setupPresenceHandlers(socket: AuthenticatedSocket, roomManager: IRoomManager) {
9-
socket.on('cursor-update', async ({ cursor }) => {
52+
socket.on('cursor-update', async ({ cursor: rawCursor }: { cursor: unknown }) => {
1053
try {
54+
// Drop a malformed/oversized cursor from an untrusted peer before it is stored or
55+
// rebroadcast (`undefined` = invalid; `null` = a legitimately cleared cursor).
56+
const cursor = normalizeCursor(rawCursor)
57+
if (cursor === undefined) return
58+
1159
const room = await roomManager.getRoomForSocket(socket.id, ROOM_TYPES.WORKFLOW)
1260
const session = await roomManager.getUserSession(socket.id)
1361

1462
if (!room || !session) return
1563

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

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

32-
socket.on('selection-update', async ({ selection }) => {
79+
socket.on('selection-update', async ({ selection: rawSelection }: { selection: unknown }) => {
3380
try {
81+
// Drop a malformed/oversized selection from an untrusted peer before it is stored
82+
// or rebroadcast.
83+
const selection = normalizeSelection(rawSelection)
84+
if (selection === undefined) return
85+
3486
const room = await roomManager.getRoomForSocket(socket.id, ROOM_TYPES.WORKFLOW)
3587
const session = await roomManager.getUserSession(socket.id)
3688

3789
if (!room || !session) return
3890

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

4293
// Broadcast to other users in the room (workflow room name is the bare id)

‎apps/realtime/src/rooms/redis-manager.ts‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,39 @@ redis.call('EXPIRE', socketSessionKey, sessionTtl)
122122
return 1
123123
`
124124

125+
/**
126+
* Ceiling on the JSON length of a single presence field. Every legitimate payload is far
127+
* below this — a cursor is tens of characters, and the largest handler-bounded selection
128+
* (two cell refs whose ids cap at 200) stays under 500 — so this never trims real presence.
129+
* It is a backstop for presence-bearing events whose handler validation is missing or
130+
* regresses, capping what one socket can park in the shared room hash and fan out to peers.
131+
*/
132+
const MAX_PRESENCE_FIELD_LENGTH = 4096
133+
134+
/**
135+
* Serialize one presence field for the activity script. Returns `''` when there is no
136+
* update (the script skips the field) and, defensively, when the value exceeds
137+
* {@link MAX_PRESENCE_FIELD_LENGTH} — dropping just that field rather than the whole
138+
* update, so a single oversized field can't suppress the others or the activity refresh.
139+
*/
140+
function serializePresenceField(
141+
field: 'cursor' | 'selection' | 'cell',
142+
value: unknown,
143+
socketId: string
144+
): string {
145+
if (value === undefined) return ''
146+
const serialized = JSON.stringify(value)
147+
if (serialized.length > MAX_PRESENCE_FIELD_LENGTH) {
148+
logger.warn('Dropping oversized presence field', {
149+
field,
150+
socketId,
151+
length: serialized.length,
152+
})
153+
return ''
154+
}
155+
return serialized
156+
}
157+
125158
/**
126159
* Redis-backed room manager for multi-pod deployments. Domain-neutral: keyed by
127160
* {@link RoomRef}, supports a socket in multiple rooms (one per {@link RoomType}).
@@ -370,14 +403,14 @@ export class RedisRoomManager implements IRoomManager {
370403
keys: [KEYS.roomUsers(room), KEYS.socketRooms(socketId), KEYS.socketSession(socketId)],
371404
arguments: [
372405
socketId,
373-
updates.cursor !== undefined ? JSON.stringify(updates.cursor) : '',
374-
updates.selection !== undefined ? JSON.stringify(updates.selection) : '',
406+
serializePresenceField('cursor', updates.cursor, socketId),
407+
serializePresenceField('selection', updates.selection, socketId),
375408
(updates.lastActivity ?? Date.now()).toString(),
376409
SOCKET_ROOMS_TTL.toString(),
377410
SESSION_TTL.toString(),
378411
// Trailing arg (ARGV[7]) so existing indices stay stable. `null` (cleared
379412
// selection) serializes to 'null'; `undefined` (no cell change) to '' (skip).
380-
updates.cell !== undefined ? JSON.stringify(updates.cell) : '',
413+
serializePresenceField('cell', updates.cell, socketId),
381414
],
382415
})
383416
} catch (error) {

‎apps/realtime/src/rooms/types.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ export interface UserPresence {
1717
joinedAt: number
1818
lastActivity: number
1919
role: string
20-
cursor?: { x: number; y: number }
20+
/** The viewer's pointer position. `null` clears it (the pointer left the canvas). */
21+
cursor?: { x: number; y: number } | null
2122
selection?: { type: 'block' | 'edge' | 'none'; id?: string }
2223
/** The viewer's current table cell selection, for table presence rooms. */
2324
cell?: TableCellSelection

0 commit comments

Comments
 (0)