297 lines
11 KiB
TypeScript
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()
|
|
}
|
|
})
|
|
})
|