diff --git a/multiplayer-next.md b/multiplayer-next.md index 6282dadc..a4d573bf 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1459,6 +1459,8 @@ The matchmaking UI now exposes that retained replay through its existing action The control plane now exports bounded Prometheus-compatible API request counters and latency summaries at `GET /metrics`, with fixed operation/status labels and no event-stream wrapping. Production and testkit services wire the collector; adversarial tests verify unknown paths cannot inject label cardinality or leak URL secrets, and full Go/race/vet checks pass. Durable SLO dashboards and alert routing remain operational work. +The authenticated event stream now rejects client data/reserved opcodes and oversized control frames at the parser boundary; only RFC 6455 close, ping, and pong frames are accepted from clients, preserving the bounded v1 stream contract. + `make verify-multiplayer-local` now provides one cloud-free regression gate for the current implementation: the complete Go suite, the Godot harness, OpenAPI parsing, and the migration/Fleet/Kubernetes/supply-chain checks. It fails clearly when the configured Godot executable is unavailable and does not weaken or replace the existing Phase 6/ENet gates; PostgreSQL, Redis, Steam, Agones, and multi-process Internet gates remain separate. That local gate now also runs `go test -race ./...`, `go vet ./...`, and each declared domain fuzz target for a bounded 2-second interval, aligning the one-command gate with the separately recorded 8.46 verification requirements. diff --git a/server/api/events.go b/server/api/events.go index 11973783..d90e215d 100644 --- a/server/api/events.go +++ b/server/api/events.go @@ -317,6 +317,10 @@ func readWebSocketFrame(reader *bufio.Reader) (byte, []byte, error) { if first&0x70 != 0 || first&0x80 == 0 { return 0, nil, errors.New("unsupported websocket frame") } + opcode := first & 0x0f + if opcode != 0x8 && opcode != 0x9 && opcode != 0xA { + return 0, nil, errors.New("unsupported websocket opcode") + } if second&0x80 == 0 { return 0, nil, errors.New("unmasked websocket frame") } @@ -337,6 +341,9 @@ func readWebSocketFrame(reader *bufio.Reader) (byte, []byte, error) { if length > maxWebSocketFrame { return 0, nil, errors.New("websocket frame too large") } + if opcode&0x8 != 0 && length > 125 { + return 0, nil, errors.New("websocket control frame too large") + } var mask [4]byte if _, err := io.ReadFull(reader, mask[:]); err != nil { return 0, nil, err @@ -348,7 +355,7 @@ func readWebSocketFrame(reader *bufio.Reader) (byte, []byte, error) { for i := range payload { payload[i] ^= mask[i%4] } - return first & 0x0f, payload, nil + return opcode, payload, nil } func writeWebSocketFrame(connection net.Conn, opcode byte, payload []byte) error { diff --git a/server/api/events_test.go b/server/api/events_test.go index 681b295a..7376055c 100644 --- a/server/api/events_test.go +++ b/server/api/events_test.go @@ -1,12 +1,30 @@ package api import ( + "bufio" + "bytes" "net/http" "net/http/httptest" "testing" "time" ) +func TestWebSocketReaderRejectsClientDataAndReservedOpcodes(t *testing.T) { + for _, opcode := range []byte{0x0, 0x1, 0x2, 0x3, 0xB, 0xF} { + frame := append([]byte{0x80 | opcode, 0x80, 0, 0, 0, 0}, nil...) + if _, _, err := readWebSocketFrame(bufio.NewReader(bytes.NewReader(frame))); err == nil { + t.Fatalf("opcode 0x%x was accepted", opcode) + } + } +} + +func TestWebSocketReaderRejectsOversizedControlFrame(t *testing.T) { + frame := append([]byte{0x89, 0xFE, 0, 126, 0, 0, 0, 0}, bytes.Repeat([]byte{0}, 126)...) + if _, _, err := readWebSocketFrame(bufio.NewReader(bytes.NewReader(frame))); err == nil { + t.Fatal("oversized ping control frame was accepted") + } +} + func TestWebSocketHandshakeRequiresRFC6455Version(t *testing.T) { service := &Service{} request := httptest.NewRequest(http.MethodGet, "/v1/events", nil)