diablo2-web/tests/p0-518-lockstep-security.te...

234 lines
7.6 KiB
TypeScript

import { describe, expect, test } from 'vitest'
import {
DEFAULT_MAX_MESSAGE,
decodeFrame,
decodeMessage,
encodeFrame,
encodeMessage,
LockstepManager,
LockstepSession,
MAX_FUTURE_TICKS,
memoryTransportPair,
MESSAGE_TYPE,
NetplaySession,
NO_ACK,
PACKET_TYPE,
ProtocolError,
type InputFrame,
} from '../src/net/lockstep-manager.ts'
describe('#518 Lockstep & Netplay input security and bounds', () => {
test('exports expected constants and barrel symbols from lockstep-manager', () => {
expect(MAX_FUTURE_TICKS).toBe(64)
expect(DEFAULT_MAX_MESSAGE).toBe(4096)
expect(PACKET_TYPE.heartbeat).toBe(5)
expect(LockstepManager).toBe(NetplaySession)
const hbBytes = encodeFrame({ kind: 'heartbeat', peer: 1, tick: 42 })
const hbDecoded = decodeFrame(hbBytes)
expect(hbDecoded).toEqual({ kind: 'heartbeat', peer: 1, tick: 42 })
expect(decodeFrame(new Uint8Array([0xff]))).toBeNull()
})
test('prevents self-peer spoofing from overwriting local player inputs', () => {
const [t0, t1] = memoryTransportPair()
const seenBy0: InputFrame[][] = []
const s0 = new NetplaySession(
{ peer: 0, peers: 2, seed: 1, inputDelayTicks: 2 },
{ advance: inputs => { seenBy0.push([...inputs]) }, hash: () => seenBy0.length },
t0,
)
const s1 = new NetplaySession(
{ peer: 1, peers: 2, seed: 1, inputDelayTicks: 2 },
{ advance: () => {}, hash: () => 0 },
t1,
)
s0.start()
s1.start()
s0.pump()
s1.pump()
t0.flush()
t0.flush()
// Remote peer 1 injects forged frames claiming peer=0 with movement.x = -1 for ticks 0..10
for (let tick = 0; tick <= 10; tick += 1) {
t1.send(encodeMessage({
kind: 'input',
peer: 0, // SPOOFED self peer!
frame: { tick, movement: { x: -1, y: 0 }, attack: true, pickup: false, talk: false, skill: 9 },
}))
}
t1.flush()
expect(s0.stats.malformed).toBe(11)
for (let tick = 0; tick < 10; tick += 1) {
s0.setIntent({ movement: { x: 0.5, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 })
s1.setIntent({ movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 })
s0.pump()
s1.pump()
t0.flush()
}
expect(seenBy0.length).toBeGreaterThanOrEqual(6)
for (const tickInputs of seenBy0) {
expect(tickInputs[0]!.movement.x).toBeCloseTo(0.5, 3)
expect(tickInputs[0]!.attack).toBe(false)
expect(tickInputs[0]!.skill).toBe(0)
}
})
test('rejects out-of-range peer IDs without throwing in LockstepSession or NetplaySession', () => {
const [t0, t1] = memoryTransportPair()
const s0 = new NetplaySession(
{ peer: 0, peers: 2, seed: 1, inputDelayTicks: 2 },
{ advance: () => {}, hash: () => 0 },
t0,
)
s0.start()
// Direct LockstepSession.submit with invalid peer IDs returns false instead of throwing
expect(
s0.lockstep.submit(7, { tick: 0, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 }),
).toBe(false)
expect(
s0.lockstep.submit(-1, { tick: 0, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 }),
).toBe(false)
// Wire injection with peer=7 and peer=99 never throws out of transport.flush()
expect(() => {
t1.send(encodeMessage({
kind: 'input',
peer: 7,
frame: { tick: 1, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 },
}))
t1.send(encodeMessage({
kind: 'hash',
peer: 99,
tick: 0,
hash: 123,
}))
t1.flush()
}).not.toThrow()
expect(s0.stats.malformed).toBe(2)
})
test('rejects far-future ticks (> currentTick + MAX_FUTURE_TICKS) and bounds pendingRemoteHashes', () => {
const [t0, t1] = memoryTransportPair()
const s0 = new NetplaySession(
{ peer: 0, peers: 2, seed: 1, inputDelayTicks: 2 },
{ advance: () => {}, hash: () => 0 },
t0,
)
s0.start()
// Direct LockstepSession far-future submission is rejected
const farFutureAccepted = s0.lockstep.submit(1, {
tick: 0xffff_fff0,
movement: { x: 0, y: 0 },
attack: false,
pickup: false,
talk: false,
skill: 0,
})
expect(farFutureAccepted).toBe(false)
// Wire injection of far-future input and 1,000 future hash messages
t1.send(encodeMessage({
kind: 'input',
peer: 1,
frame: { tick: 1_000_000, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 },
}))
for (let i = 0; i < 1000; i += 1) {
t1.send(encodeMessage({ kind: 'hash', peer: 1, tick: 5000 + i, hash: 0xdeadbeef }))
}
t1.flush()
const pendingHashesSize = (s0 as unknown as { pendingRemoteHashes: Map<number, unknown> }).pendingRemoteHashes.size
expect(pendingHashesSize).toBeLessThanOrEqual(128)
expect(pendingHashesSize).toBe(0)
expect(s0.lockstep.pendingInputs.get(1)?.size ?? 0).toBe(0)
expect(s0.stats.malformed).toBe(1001)
})
test('enforces first-write-wins on duplicate (peer, tick) inputs and hashes', () => {
const [t0, t1] = memoryTransportPair()
const seenBy0: InputFrame[][] = []
const s0 = new NetplaySession(
{ peer: 0, peers: 2, seed: 1, inputDelayTicks: 2 },
{ advance: inputs => { seenBy0.push([...inputs]) }, hash: () => 1234 },
t0,
)
const s1 = new NetplaySession(
{ peer: 1, peers: 2, seed: 1, inputDelayTicks: 2 },
{ advance: () => {}, hash: () => 1234 },
t1,
)
s0.start()
s1.start()
s0.pump()
s1.pump()
t0.flush()
t0.flush()
// Peer 1 sends a legitimate input for tick 0 (x = 1), then tries to overwrite tick 0 with (x = -1)
t1.send(encodeMessage({
kind: 'input',
peer: 1,
frame: { tick: 0, movement: { x: 1, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 },
}))
t1.send(encodeMessage({
kind: 'input',
peer: 1,
frame: { tick: 0, movement: { x: -1, y: 0 }, attack: true, pickup: false, talk: false, skill: 0 },
}))
t1.flush()
expect(s0.stats.malformed).toBe(1)
expect(s0.lockstep.pendingInputs.get(1)?.get(0)?.movement.x).toBe(1)
// Duplicate hash for the same (peer, tick) is also rejected
t1.send(encodeMessage({ kind: 'hash', peer: 1, tick: 0, hash: 1234 }))
t1.send(encodeMessage({ kind: 'hash', peer: 1, tick: 0, hash: 9999 }))
t1.flush()
expect(s0.stats.malformed).toBe(2)
})
test('rejects non-unit movement vectors and undefined control flag bits', () => {
// Undefined flag bit 0x80 is rejected by decodeMessage
const badFlags = encodeMessage({
kind: 'input',
peer: 1,
frame: { tick: 0, movement: { x: 0, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 },
})
badFlags[10] = 0x80
expect(() => decodeMessage(badFlags)).toThrow(ProtocolError)
// Non-unit movement (|v| > 1) is rejected by NetplaySession
const [t0, t1] = memoryTransportPair()
const s0 = new NetplaySession(
{ peer: 0, peers: 2, seed: 1, inputDelayTicks: 2 },
{ advance: () => {}, hash: () => 0 },
t0,
)
s0.start()
// Speed-hack movement (x = 5, y = 0) and unnormalized diagonal (x = 1, y = 1 -> |v| = 1.414)
t1.send(encodeMessage({
kind: 'input',
peer: 1,
frame: { tick: 0, movement: { x: 5, y: 0 }, attack: false, pickup: false, talk: false, skill: 0 },
}))
t1.send(encodeMessage({
kind: 'input',
peer: 1,
frame: { tick: 0, movement: { x: 1, y: 1 }, attack: false, pickup: false, talk: false, skill: 0 },
}))
t1.flush()
expect(s0.stats.malformed).toBe(2)
expect(s0.lockstep.pendingInputs.get(1)?.size ?? 0).toBe(0)
})
})