Files
upstream-hocuspocus/tests/server/unauthenticatedQueueLimit.ts
upstream-mirror e0c588ca1c
Some checks failed
build / lint (22) (push) Failing after 17s
build / test (22) (push) Failing after 8s
build / lint (23) (push) Failing after 2s
build / build (22) (push) Has been skipped
build / build (23) (push) Has been skipped
build / test (23) (push) Failing after 4s
docsearch / docsearch (push) Failing after 1s
snapshot of https://github.com/ueberdosis/hocuspocus.git @ cf3cfa355b4d3bb61c6b6c80071f71783e32470e (2026-09-15)
2026-09-15 17:21:35 +05:00

228 lines
8.4 KiB
TypeScript

import test from 'ava'
import * as encoding from 'lib0/encoding'
import { writeAuthentication } from '@hocuspocus/common'
import { newHocuspocus } from '../utils/index.ts'
// Wire-level MessageType values (mirrors packages/server/src/types.ts).
const MessageType = {
Auth: 2,
Stateless: 5,
}
const buildStatelessFrame = (documentName: string, payloadSize: number): Uint8Array => {
const encoder = encoding.createEncoder()
encoding.writeVarString(encoder, documentName)
encoding.writeVarUint(encoder, MessageType.Stateless)
encoding.writeVarString(encoder, 'A'.repeat(payloadSize))
return encoding.toUint8Array(encoder)
}
const buildAuthFrame = (documentName: string, token: string): Uint8Array => {
const encoder = encoding.createEncoder()
encoding.writeVarString(encoder, documentName)
encoding.writeVarUint(encoder, MessageType.Auth)
writeAuthentication(encoder, token)
return encoding.toUint8Array(encoder)
}
const buildCloseFrame = (documentName: string): Uint8Array => {
const encoder = encoding.createEncoder()
encoding.writeVarString(encoder, documentName)
encoding.writeVarUint(encoder, 7) // MessageType.CLOSE
return encoding.toUint8Array(encoder)
}
const openRawSocket = (url: string): Promise<WebSocket> => new Promise((resolve, reject) => {
const ws = new WebSocket(url)
ws.binaryType = 'arraybuffer'
ws.addEventListener('open', () => resolve(ws), { once: true })
ws.addEventListener('error', (event) => reject(event), { once: true })
})
// Wait for the server to close the socket, returning the close code.
const waitForClose = (ws: WebSocket, timeoutMs = 5000): Promise<number> => new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('connection was not closed by the server')), timeoutMs)
ws.addEventListener('close', (event) => {
clearTimeout(timer)
resolve(event.code)
}, { once: true })
})
test('closes an unauthenticated connection that exceeds the pre-auth message-count limit', async t => {
const server = await newHocuspocus(t, {
maxUnauthenticatedQueueMessages: 5,
})
const attacker = await openRawSocket(server.server!.webSocketURL)
t.teardown(() => attacker.close())
const closed = waitForClose(attacker)
// Flood many small non-Auth frames before authenticating. The server must
// stop queuing and close the connection once the count limit is exceeded.
for (let i = 0; i < 50; i += 1) {
attacker.send(buildStatelessFrame('queue-count-doc', 16))
}
const code = await closed
t.is(code, 4205, 'connection should be closed with ResetConnection (4205)')
})
test('closes an unauthenticated connection that exceeds the pre-auth byte limit', async t => {
const server = await newHocuspocus(t, {
// 256 KB total budget across the unauthenticated queue.
maxUnauthenticatedQueueSize: 256 * 1024,
})
const attacker = await openRawSocket(server.server!.webSocketURL)
t.teardown(() => attacker.close())
const closed = waitForClose(attacker)
// Each frame carries ~64 KB; the 5th frame crosses the 256 KB budget.
for (let i = 0; i < 20; i += 1) {
attacker.send(buildStatelessFrame('queue-bytes-doc', 64 * 1024))
}
const code = await closed
t.is(code, 4205, 'connection should be closed with ResetConnection (4205)')
})
test('closes an unauthenticated connection that opens too many pending documents', async t => {
const server = await newHocuspocus(t, {
maxPendingDocuments: 3,
})
const attacker = await openRawSocket(server.server!.webSocketURL)
t.teardown(() => attacker.close())
const closed = waitForClose(attacker)
// Spread a single small frame across many distinct document keys. A per-key
// limit alone would not catch this amplification; a per-connection cap does.
for (let i = 0; i < 25; i += 1) {
attacker.send(buildStatelessFrame(`pending-doc-${i}`, 16))
}
const code = await closed
t.is(code, 4205, 'connection should be closed with ResetConnection (4205)')
})
test('closes a continuously-sending unauthenticated connection once the pre-auth timeout elapses', async t => {
const server = await newHocuspocus(t, {
timeout: 1000,
// Keep queue limits high so the timeout — not the queue cap — is what fires.
maxUnauthenticatedQueueMessages: 1_000_000,
maxUnauthenticatedQueueSize: 1024 * 1024 * 1024,
})
const attacker = await openRawSocket(server.server!.webSocketURL)
const closed = waitForClose(attacker, 6000)
// Keep refreshing activity with tiny frames. The pre-auth deadline must not
// be reset by inbound traffic, so the connection still closes.
const interval = setInterval(() => {
if (attacker.readyState === WebSocket.OPEN) {
attacker.send(buildStatelessFrame('timeout-doc', 8))
}
}, 200)
t.teardown(() => {
clearInterval(interval)
attacker.close()
})
const code = await closed
clearInterval(interval)
t.is(code, 4408, 'connection should be closed with ConnectionTimeout (4408)')
})
test('counts in-flight authentications toward the pending-document limit', async t => {
const server = await newHocuspocus(t, {
maxPendingDocuments: 3,
// Never resolves, so every document stays in the "authenticating" state.
// Sending Auth first marks a document as establishing but keeps its hook
// payload alive while onAuthenticate runs — this must still count.
onAuthenticate: () => new Promise(() => {}),
})
const attacker = await openRawSocket(server.server!.webSocketURL)
t.teardown(() => attacker.close())
const closed = waitForClose(attacker)
// Send Auth as the FIRST frame for many distinct documents.
for (let i = 0; i < 25; i += 1) {
attacker.send(buildAuthFrame(`auth-first-doc-${i}`, ''))
}
const code = await closed
t.is(code, 4205, 'connection should be closed with ResetConnection (4205)')
})
test('does not close an authenticated connection that has closed all of its documents', async t => {
const server = await newHocuspocus(t, {
timeout: 600,
})
const attacker = await openRawSocket(server.server!.webSocketURL)
t.teardown(() => attacker.close())
// Authenticate a document (no onAuthenticate hook => allowed).
const authenticated = new Promise<void>((resolve) => {
attacker.addEventListener('message', () => resolve(), { once: true })
})
attacker.send(buildAuthFrame('soon-empty-doc', ''))
await authenticated
let closedCode: number | undefined
attacker.addEventListener('close', (event) => { closedCode = event.code }, { once: true })
// Keep the socket alive with periodic frames so `lastMessageReceivedAt` stays
// fresh. Halfway through, close the only document, leaving zero established
// document connections. Because the connection already authenticated, the idle
// check must keep using `lastMessageReceivedAt` rather than the original open
// time — so the socket must survive past `timeout` measured from open.
const interval = setInterval(() => {
if (attacker.readyState === WebSocket.OPEN) {
attacker.send(buildStatelessFrame('keepalive-doc', 8))
}
}, 150)
t.teardown(() => clearInterval(interval))
await new Promise((resolve) => setTimeout(resolve, 700))
attacker.send(buildCloseFrame('soon-empty-doc'))
await new Promise((resolve) => setTimeout(resolve, 1100))
clearInterval(interval)
t.is(closedCode, undefined, 'authenticated connection must not be closed by the pre-auth deadline')
})
test('does not close an authenticated connection that later exceeds the pre-auth limits', async t => {
const server = await newHocuspocus(t, {
maxUnauthenticatedQueueMessages: 5,
maxUnauthenticatedQueueSize: 64 * 1024,
})
const attacker = await openRawSocket(server.server!.webSocketURL)
t.teardown(() => attacker.close())
// Authenticate first (no onAuthenticate hook => allowed).
const authenticated = new Promise<void>((resolve) => {
attacker.addEventListener('message', () => resolve(), { once: true })
})
attacker.send(buildAuthFrame('authenticated-doc', ''))
await authenticated
// After auth, large/many frames flow through the established connection and
// are not subject to the pre-auth queue caps.
let closedEarly = false
attacker.addEventListener('close', () => { closedEarly = true }, { once: true })
for (let i = 0; i < 50; i += 1) {
attacker.send(buildStatelessFrame('authenticated-doc', 64 * 1024))
}
await new Promise((resolve) => setTimeout(resolve, 300))
t.false(closedEarly, 'authenticated connection must not be closed by pre-auth queue limits')
})