diablo2-web/tests/p0-517-relay-hardening.test.ts

297 lines
11 KiB
TypeScript

import { createConnection } from 'node:net'
import { randomBytes } from 'node:crypto'
import { describe, expect, test } from 'vitest'
import { decodeFrames, encodeFrame, startRelay } from '../scripts/net-relay.ts'
import { decodeMessage, encodeMessage, MESSAGE_TYPE, NO_ACK } from '../src/net/protocol.ts'
/**
* Build a masked client WebSocket frame for raw TCP testing.
*/
function encodeMaskedClientFrame(
opcode: number,
payload: Uint8Array,
options: { readonly fin?: boolean; readonly rsv?: number; readonly masked?: boolean } = {},
): Uint8Array {
const fin = options.fin ?? true
const rsv = options.rsv ?? 0
const masked = options.masked ?? true
const maskKey = new Uint8Array([0x12, 0x34, 0x56, 0x78])
const len = payload.byteLength
const headerLen = (len < 126 ? 2 : len < 65536 ? 4 : 10) + (masked ? 4 : 0)
const out = new Uint8Array(headerLen + len)
out[0] = (fin ? 0x80 : 0x00) | ((rsv & 0x7) << 4) | (opcode & 0x0f)
let cursor = 2
if (len < 126) {
out[1] = (masked ? 0x80 : 0x00) | len
} else if (len < 65536) {
out[1] = (masked ? 0x80 : 0x00) | 126
out[2] = (len >>> 8) & 0xff
out[3] = len & 0xff
cursor = 4
} else {
out[1] = (masked ? 0x80 : 0x00) | 127
const view = new DataView(out.buffer)
view.setUint32(2, 0, false)
view.setUint32(6, len, false)
cursor = 10
}
if (masked) {
out.set(maskKey, cursor)
cursor += 4
for (let i = 0; i < len; i += 1) {
out[cursor + i] = payload[i]! ^ maskKey[i & 3]!
}
} else {
out.set(payload, cursor)
}
return out
}
/**
* Open a raw TCP socket and complete the RFC 6455 HTTP Upgrade handshake.
*/
async function openRawWebSocket(port: number, path = '/'): Promise<{
readonly sendRaw: (bytes: Uint8Array) => void
readonly waitForCloseFrame: (timeoutMs?: number) => Promise<number | null>
readonly close: () => void
}> {
return await new Promise((resolve, reject) => {
const socket = createConnection({ host: '127.0.0.1', port })
const key = randomBytes(16).toString('base64')
let upgraded = false
const chunks: Buffer[] = []
socket.on('connect', () => {
socket.write(
`GET ${path} HTTP/1.1\r\n`
+ `Host: 127.0.0.1:${String(port)}\r\n`
+ 'Upgrade: websocket\r\n'
+ 'Connection: Upgrade\r\n'
+ `Sec-WebSocket-Key: ${key}\r\n`
+ 'Sec-WebSocket-Version: 13\r\n\r\n',
)
})
socket.on('data', (chunk: Buffer) => {
if (!upgraded) {
const text = chunk.toString('latin1')
const idx = text.indexOf('\r\n\r\n')
if (idx !== -1) {
upgraded = true
const rest = chunk.subarray(idx + 4)
if (rest.byteLength > 0) chunks.push(rest)
resolve({
sendRaw: (bytes: Uint8Array) => { socket.write(bytes) },
waitForCloseFrame: async (timeoutMs = 1000): Promise<number | null> => {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const combined = Buffer.concat(chunks)
for (let i = 0; i + 4 <= combined.byteLength; i += 1) {
if (combined[i] === 0x88 && (combined[i + 1]! & 0x7f) >= 2) {
return (combined[i + 2]! << 8) | combined[i + 3]!
}
}
if (socket.destroyed) break
await new Promise(r => { setTimeout(r, 10) })
}
return null
},
close: () => { socket.destroy() },
})
}
} else {
chunks.push(chunk)
}
})
socket.on('error', err => {
if (!upgraded) reject(err)
})
})
}
describe('#517 WebSocket relay hardening', () => {
test('decodeFrames rejects RFC 6455 framing violations with structured error codes without throwing', () => {
// 1. 64-bit length > maxPayloadBytes (10 MB header, only 14 bytes sent)
const header64 = new Uint8Array(14)
header64[0] = 0x82
header64[1] = 0x80 | 127
new DataView(header64.buffer).setBigUint64(2, 10_000_000n, false)
const res64 = decodeFrames(header64, 4096)
expect(res64.error?.code).toBe(1009)
// 2. Non-zero RSV bits
const rsvFrame = encodeMaskedClientFrame(0x2, new Uint8Array([1, 2]), { rsv: 1 })
expect(decodeFrames(rsvFrame).error?.code).toBe(1002)
// 3. Unmasked client frame
const unmaskedFrame = encodeMaskedClientFrame(0x2, new Uint8Array([1, 2]), { masked: false })
expect(decodeFrames(unmaskedFrame).error?.code).toBe(1002)
// 4. Fragmented frame (FIN=0)
const fragmented = encodeMaskedClientFrame(0x2, new Uint8Array([1, 2]), { fin: false })
expect(decodeFrames(fragmented).error?.code).toBe(1002)
// 5. Text frame (OP_TEXT -> 1003)
const textFrame = encodeMaskedClientFrame(0x1, new Uint8Array([65, 66]))
expect(decodeFrames(textFrame).error?.code).toBe(1003)
// 6. 1-byte close frame (-> 1002)
const oneByteClose = encodeMaskedClientFrame(0x8, new Uint8Array([0x03]))
expect(decodeFrames(oneByteClose).error?.code).toBe(1002)
// 7. Invalid close status code 1005 on wire (-> 1002)
const badCloseCode = encodeMaskedClientFrame(0x8, new Uint8Array([0x03, 0xed]))
expect(decodeFrames(badCloseCode).error?.code).toBe(1002)
// 8. Invalid UTF-8 in close reason (-> 1007)
const badUtf8Close = encodeMaskedClientFrame(0x8, new Uint8Array([0x03, 0xe8, 0xff, 0xfe]))
expect(decodeFrames(badUtf8Close).error?.code).toBe(1007)
})
test('relay survives 64-bit oversized frame and malformed RFC 6455 frames while keeping healthy peers alive', async () => {
const relay = await startRelay(0)
try {
const port = Number(new URL(relay.url).port)
const wsGoodA = new WebSocket(relay.url)
const wsGoodB = new WebSocket(relay.url)
await Promise.all([
new Promise<void>(r => { wsGoodA.addEventListener('open', () => { r() }) }),
new Promise<void>(r => { wsGoodB.addEventListener('open', () => { r() }) }),
])
const receivedAtB: Uint8Array[] = []
wsGoodB.binaryType = 'arraybuffer'
wsGoodB.addEventListener('message', ev => {
if (ev.data instanceof ArrayBuffer) receivedAtB.push(new Uint8Array(ev.data))
})
// Attacker sends a 64-bit length frame claiming 10 MB payload
const attacker1 = await openRawWebSocket(port)
const header = new Uint8Array(14)
header[0] = 0x82
header[1] = 0x80 | 127
new DataView(header.buffer).setBigUint64(2, 10_000_000n, false)
attacker1.sendRaw(header)
const code1 = await attacker1.waitForCloseFrame()
expect(code1).toBe(1009)
attacker1.close()
// Attacker 2 sends an unmasked client frame
const attacker2 = await openRawWebSocket(port)
attacker2.sendRaw(encodeMaskedClientFrame(0x2, new Uint8Array([1, 2, 3, 4]), { masked: false }))
const code2 = await attacker2.waitForCloseFrame()
expect(code2).toBe(1002)
attacker2.close()
// Healthy peers A and B can still exchange valid lockstep messages
const hello = encodeMessage({ kind: 'hello', peer: 0, peers: 2, seed: 0x12345678, ackTo: NO_ACK })
wsGoodA.send(hello)
await new Promise(r => { setTimeout(r, 40) })
expect(receivedAtB.length).toBe(1)
const decoded = decodeMessage(receivedAtB[0]!)
expect(decoded.kind).toBe('hello')
wsGoodA.close()
wsGoodB.close()
} finally {
await relay.close()
}
})
test('relay drops malformed application payloads without disconnecting and binds sender peerId', async () => {
const relay = await startRelay(0)
try {
const wsA = new WebSocket(`${relay.url}/?peer=0`)
const wsB = new WebSocket(`${relay.url}/?peer=1`)
await Promise.all([
new Promise<void>(r => { wsA.addEventListener('open', () => { r() }) }),
new Promise<void>(r => { wsB.addEventListener('open', () => { r() }) }),
])
const receivedAtA: Uint8Array[] = []
const receivedAtB: Uint8Array[] = []
wsA.binaryType = 'arraybuffer'
wsB.binaryType = 'arraybuffer'
wsA.addEventListener('message', ev => {
if (ev.data instanceof ArrayBuffer) receivedAtA.push(new Uint8Array(ev.data))
})
wsB.addEventListener('message', ev => {
if (ev.data instanceof ArrayBuffer) receivedAtB.push(new Uint8Array(ev.data))
})
// Send malformed application frames from A
wsA.send(new Uint8Array(0))
wsA.send(new Uint8Array([0x01]))
wsA.send(new Uint8Array([0x00, 0xff, 0xff, 0xff]))
wsA.send(new Uint8Array([MESSAGE_TYPE.input])) // truncated input
wsA.send(new Uint8Array([MESSAGE_TYPE.hash, 0x00, 0x01])) // truncated hash
wsA.send(new Uint8Array([0x99, 0x00])) // unknown type
await new Promise(r => { setTimeout(r, 40) })
expect(receivedAtB.length).toBe(0)
expect(relay.dropped).toBeGreaterThanOrEqual(6)
// Subsequent valid frame from A is still routed to B
const validHelloA = encodeMessage({ kind: 'hello', peer: 0, peers: 2, seed: 42, ackTo: NO_ACK })
wsA.send(validHelloA)
await new Promise(r => { setTimeout(r, 40) })
expect(receivedAtB.length).toBe(1)
// Peer B attempts to spoof peer=0 in an input message; relay rewrites peerId to 1
const spoofedFromB = encodeMessage({
kind: 'input',
peer: 0, // spoofed! B is assigned peerId=1
frame: { tick: 3, movement: { x: 1, y: 0 }, attack: true, pickup: false, talk: false, skill: 0 },
})
wsB.send(spoofedFromB)
await new Promise(r => { setTimeout(r, 40) })
expect(receivedAtA.length).toBe(1)
const fromB = decodeMessage(receivedAtA[0]!)
expect(fromB.kind).toBe('input')
expect(fromB.peer).toBe(1)
wsA.close()
wsB.close()
} finally {
await relay.close()
}
})
test('relay enforces room capacity and encodeFrame round-trips', async () => {
const sample = new Uint8Array([1, 2, 3, 4])
const framed = encodeFrame(0x2, sample)
expect(framed[0]).toBe(0x82)
expect(framed[1]).toBe(4)
const relay = await startRelay(0, { maxPeersPerRoom: 2 })
try {
const ws0 = new WebSocket(`${relay.url}/?room=test`)
const ws1 = new WebSocket(`${relay.url}/?room=test`)
await Promise.all([
new Promise<void>(r => { ws0.addEventListener('open', () => { r() }) }),
new Promise<void>(r => { ws1.addEventListener('open', () => { r() }) }),
])
expect(relay.clients).toBe(2)
// 3rd connection to the same 2-peer room is rejected
const ws2 = new WebSocket(`${relay.url}/?room=test`)
const closedThird = await new Promise<boolean>(resolve => {
ws2.addEventListener('open', () => { resolve(false) })
ws2.addEventListener('error', () => { resolve(true) })
ws2.addEventListener('close', () => { resolve(true) })
})
expect(closedThird).toBe(true)
expect(relay.clients).toBe(2)
ws0.close()
ws1.close()
} finally {
await relay.close()
}
})
})