Skip to content

Commit c45e8b5

Browse files
authored
fix(realtime): validate cursor and selection presence payloads (#8041)
* 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. * fix(realtime): measure presence field cap in utf-8 bytes The cap compared UTF-16 code units against a byte budget, so a multi-byte payload could pass the check and still land several times larger in the room hash. It now measures the UTF-8 bytes Redis actually stores. Raises the ceiling to 16384. A table cell selection carries four ids capped at 200 characters each, and multi-byte characters plus JSON escaping can expand a legitimate worst case to roughly 5 KB - above the previous 4096, so the old bound could have dropped real presence.
1 parent 6b46f69 commit c45e8b5

4 files changed

Lines changed: 255 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: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,41 @@ redis.call('EXPIRE', socketSessionKey, sessionTtl)
122122
return 1
123123
`
124124

125+
/**
126+
* Ceiling on a single presence field, measured in the UTF-8 bytes Redis actually stores
127+
* rather than UTF-16 code units, so a multi-byte payload can't pass a character-based check
128+
* and still land several times larger in the room hash.
129+
*
130+
* The largest legitimate payload is a table cell selection: four ids capped at 200
131+
* characters each. Multi-byte characters and JSON escaping can expand those well past
132+
* their character count, so the realistic worst case approaches 5 KB — this sits comfortably
133+
* above that, and a backstop that could trim real presence would be worse than a loose one.
134+
* It bounds what one socket can park in the shared room hash and fan out to every peer when
135+
* a presence-bearing event's handler validation is missing or regresses.
136+
*/
137+
const MAX_PRESENCE_FIELD_BYTES = 16384
138+
139+
/**
140+
* Serialize one presence field for the activity script. Returns `''` when there is no
141+
* update (the script skips the field) and, defensively, when the value exceeds
142+
* {@link MAX_PRESENCE_FIELD_BYTES} — dropping just that field rather than the whole
143+
* update, so a single oversized field can't suppress the others or the activity refresh.
144+
*/
145+
function serializePresenceField(
146+
field: 'cursor' | 'selection' | 'cell',
147+
value: unknown,
148+
socketId: string
149+
): string {
150+
if (value === undefined) return ''
151+
const serialized = JSON.stringify(value)
152+
const bytes = Buffer.byteLength(serialized, 'utf8')
153+
if (bytes > MAX_PRESENCE_FIELD_BYTES) {
154+
logger.warn('Dropping oversized presence field', { field, socketId, bytes })
155+
return ''
156+
}
157+
return serialized
158+
}
159+
125160
/**
126161
* Redis-backed room manager for multi-pod deployments. Domain-neutral: keyed by
127162
* {@link RoomRef}, supports a socket in multiple rooms (one per {@link RoomType}).
@@ -370,14 +405,14 @@ export class RedisRoomManager implements IRoomManager {
370405
keys: [KEYS.roomUsers(room), KEYS.socketRooms(socketId), KEYS.socketSession(socketId)],
371406
arguments: [
372407
socketId,
373-
updates.cursor !== undefined ? JSON.stringify(updates.cursor) : '',
374-
updates.selection !== undefined ? JSON.stringify(updates.selection) : '',
408+
serializePresenceField('cursor', updates.cursor, socketId),
409+
serializePresenceField('selection', updates.selection, socketId),
375410
(updates.lastActivity ?? Date.now()).toString(),
376411
SOCKET_ROOMS_TTL.toString(),
377412
SESSION_TTL.toString(),
378413
// Trailing arg (ARGV[7]) so existing indices stay stable. `null` (cleared
379414
// selection) serializes to 'null'; `undefined` (no cell change) to '' (skip).
380-
updates.cell !== undefined ? JSON.stringify(updates.cell) : '',
415+
serializePresenceField('cell', updates.cell, socketId),
381416
],
382417
})
383418
} 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)