Skip to content

Commit 6872d08

Browse files
authored
fix: client-initiated broadcastStateless (#1103)
1 parent a08bfc7 commit 6872d08

2 files changed

Lines changed: 118 additions & 2 deletions

File tree

packages/server/src/MessageReceiver.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,21 @@ export class MessageReceiver {
118118
break;
119119
}
120120
case MessageType.BroadcastStateless: {
121+
// Server-internal opcode used by @hocuspocus/extension-redis to
122+
// fan a stateless payload across server instances. The Redis path
123+
// invokes MessageReceiver without a `connection`, so a defined
124+
// `connection` here means this frame came from a WebSocket client
125+
// — which is never legitimate. Clients must use MessageType.Stateless
126+
// (opcode 5); the onStateless hook is the authorization point and
127+
// may call Document.broadcastStateless() to fan out if appropriate.
128+
if (connection) {
129+
throw new Error(
130+
"BroadcastStateless is a server-internal opcode and cannot be sent from a client",
131+
);
132+
}
121133
const msg = message.readVarString();
122-
document.getConnections().forEach((connection) => {
123-
connection.sendStateless(msg);
134+
document.getConnections().forEach((c) => {
135+
c.sendStateless(msg);
124136
});
125137
break;
126138
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import test from 'ava'
2+
import * as encoding from 'lib0/encoding'
3+
import { writeAuthentication } from '@hocuspocus/common'
4+
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
5+
6+
// Wire-level MessageType values (mirrors packages/server/src/types.ts).
7+
const MessageType = {
8+
Auth: 2,
9+
Stateless: 5,
10+
BroadcastStateless: 6,
11+
}
12+
13+
const buildAuthFrame = (documentName: string, token: string): Uint8Array => {
14+
const encoder = encoding.createEncoder()
15+
encoding.writeVarString(encoder, documentName)
16+
encoding.writeVarUint(encoder, MessageType.Auth)
17+
writeAuthentication(encoder, token)
18+
// providerVersion is optional on the server side, omit it.
19+
return encoding.toUint8Array(encoder)
20+
}
21+
22+
const buildBroadcastStatelessFrame = (documentName: string, payload: string): Uint8Array => {
23+
const encoder = encoding.createEncoder()
24+
encoding.writeVarString(encoder, documentName)
25+
encoding.writeVarUint(encoder, MessageType.BroadcastStateless)
26+
encoding.writeVarString(encoder, payload)
27+
return encoding.toUint8Array(encoder)
28+
}
29+
30+
const openRawSocket = (url: string): Promise<WebSocket> => new Promise((resolve, reject) => {
31+
const ws = new WebSocket(url)
32+
ws.binaryType = 'arraybuffer'
33+
ws.addEventListener('open', () => resolve(ws), { once: true })
34+
ws.addEventListener('error', (event) => reject(event), { once: true })
35+
})
36+
37+
test('rejects client-sent BroadcastStateless (opcode 6) — payload must not reach peers', async t => {
38+
const documentName = 'hocuspocus-test'
39+
const attackerPayload = '{"spoof":"server-system-banner","marker":"LITMUS"}'
40+
41+
let onStatelessCalls = 0
42+
let beforeBroadcastStatelessCalls = 0
43+
44+
const server = await newHocuspocus(t, {
45+
async onAuthenticate({ token, connectionConfig }) {
46+
if (token === 'readonly') {
47+
connectionConfig.readOnly = true
48+
}
49+
},
50+
async onStateless() {
51+
onStatelessCalls += 1
52+
},
53+
async beforeBroadcastStateless() {
54+
beforeBroadcastStatelessCalls += 1
55+
},
56+
})
57+
58+
const received: string[] = []
59+
const victimReady = new Promise<void>((resolve) => {
60+
newHocuspocusProvider(t, server, {
61+
name: documentName,
62+
token: 'read+write',
63+
onSynced: () => resolve(),
64+
onStateless: ({ payload }) => {
65+
received.push(payload)
66+
},
67+
})
68+
})
69+
70+
await victimReady
71+
72+
// Attacker: raw WebSocket using a readOnly token. Sends a single
73+
// BroadcastStateless (opcode 6) frame after a successful auth. The
74+
// server must reject this server-internal opcode and not fan it out.
75+
const attacker = await openRawSocket(server.server!.webSocketURL)
76+
t.teardown(() => attacker.close())
77+
78+
const authenticated = new Promise<void>((resolve, reject) => {
79+
const timer = setTimeout(() => reject(new Error('auth timeout')), 5000)
80+
attacker.addEventListener('message', () => {
81+
clearTimeout(timer)
82+
resolve()
83+
}, { once: true })
84+
})
85+
86+
attacker.send(buildAuthFrame(documentName, 'readonly'))
87+
await authenticated
88+
89+
attacker.send(buildBroadcastStatelessFrame(documentName, attackerPayload))
90+
91+
// Give the server time to (incorrectly) fan out, if the bug is present.
92+
await new Promise((resolve) => setTimeout(resolve, 200))
93+
94+
t.false(
95+
received.includes(attackerPayload),
96+
'victim must not receive payloads from client-sent BroadcastStateless frames',
97+
)
98+
t.is(onStatelessCalls, 0, 'onStateless must not fire for opcode 6 (it is not the Stateless opcode)')
99+
t.is(
100+
beforeBroadcastStatelessCalls,
101+
0,
102+
'beforeBroadcastStateless must not fire because the frame is rejected before fan-out',
103+
)
104+
})

0 commit comments

Comments
 (0)