fix(multiplayer): validate server event resource ids

This commit is contained in:
Josh Creek
2026-09-01 22:25:04 +01:00
parent fd1a4d9577
commit 429fb87c08
4 changed files with 14 additions and 6 deletions
+5 -1
View File
@@ -11,6 +11,7 @@ import (
"io"
"net"
"net/http"
"regexp"
"strings"
"sync"
"time"
@@ -27,8 +28,11 @@ const (
webSocketMessageLimit = 120
webSocketMessageWindow = time.Minute
maxEventConnectionsPerPlayer = 2
controlPlaneResourceIDPattern = `^[A-Za-z0-9_-]{16,128}$`
)
var controlPlaneResourceIDRE = regexp.MustCompile(controlPlaneResourceIDPattern)
// ControlPlaneEvent is the server-to-client envelope defined by the v1
// WebSocket contract. PlayerID is routing metadata and is never serialized.
type ControlPlaneEvent struct {
@@ -114,7 +118,7 @@ func (h *eventHub) publish(event ControlPlaneEvent) error {
}
func validateControlPlaneEvent(event ControlPlaneEvent) error {
if event.PlayerID == "" || event.ResourceID == "" || event.OccurredAt.IsZero() {
if event.PlayerID == "" || !controlPlaneResourceIDRE.MatchString(event.ResourceID) || event.OccurredAt.IsZero() {
return errors.New("invalid control-plane event envelope")
}
switch event.Event {
+5 -5
View File
@@ -17,7 +17,7 @@ func TestDeliverProposalOutboxEventPublishesEveryTarget(t *testing.T) {
defer service.getEventHub().unsubscribe(second)
payload, err := json.Marshal(map[string]any{
"event": "proposal_changed", "revision": uint64(0), "resource_id": "proposal-1",
"event": "proposal_changed", "revision": uint64(0), "resource_id": "proposal_1234567890",
"occurred_at": time.Unix(1000, 0).UTC(), "state": "OPEN", "player_ids": []string{"player-a", "player-b"},
})
if err != nil {
@@ -66,8 +66,8 @@ func TestDeliverStateOutboxEventValidatesRevisionAndTargets(t *testing.T) {
service := &Service{}
first := service.getEventHub().subscribe("player-a")
defer service.getEventHub().unsubscribe(first)
payload := []byte(`{"event":"state_changed","revision":4,"resource_id":"match-1","occurred_at":"1970-01-01T00:16:40Z","state":"ASSIGNMENT_READY","match_id":"match-1","player_ids":["player-a"]}`)
if err := deliverStateOutboxEvent(context.Background(), store.OutboxEvent{EventType: "state_changed", AggregateID: "match-1", Revision: 4, Payload: payload}, service); err != nil {
payload := []byte(`{"event":"state_changed","revision":4,"resource_id":"match_1234567890","occurred_at":"1970-01-01T00:16:40Z","state":"ASSIGNMENT_READY","match_id":"match-1","player_ids":["player-a"]}`)
if err := deliverStateOutboxEvent(context.Background(), store.OutboxEvent{EventType: "state_changed", AggregateID: "match_1234567890", Revision: 4, Payload: payload}, service); err != nil {
t.Fatalf("valid state event rejected: %v", err)
}
select {
@@ -75,8 +75,8 @@ func TestDeliverStateOutboxEventValidatesRevisionAndTargets(t *testing.T) {
case <-time.After(time.Second):
t.Fatal("participant did not receive state event")
}
bad := []byte(`{"event":"state_changed","revision":3,"resource_id":"match-1","state":"LIVE","player_ids":["player-a"]}`)
if err := deliverStateOutboxEvent(context.Background(), store.OutboxEvent{EventType: "state_changed", AggregateID: "match-1", Revision: 4, Payload: bad}, service); err == nil {
bad := []byte(`{"event":"state_changed","revision":3,"resource_id":"match_1234567890","state":"LIVE","player_ids":["player-a"]}`)
if err := deliverStateOutboxEvent(context.Background(), store.OutboxEvent{EventType: "state_changed", AggregateID: "match_1234567890", Revision: 4, Payload: bad}, service); err == nil {
t.Fatal("revision-mismatched state event accepted")
}
}
+2
View File
@@ -432,6 +432,8 @@ func TestEventHubRejectsEventsOutsideTheV1Vocabulary(t *testing.T) {
base := ControlPlaneEvent{Revision: 1, ResourceID: "ticket-1234567890123456", OccurredAt: time.Unix(1000, 0).UTC(), PlayerID: "player-1"}
invalid := []ControlPlaneEvent{
{Event: "unknown", Revision: base.Revision, ResourceID: base.ResourceID, OccurredAt: base.OccurredAt, PlayerID: base.PlayerID},
{Event: "state_changed", State: "QUEUED", Revision: base.Revision, ResourceID: "short", OccurredAt: base.OccurredAt, PlayerID: base.PlayerID},
{Event: "state_changed", State: "QUEUED", Revision: base.Revision, ResourceID: "ticket-1234567890/unsafe", OccurredAt: base.OccurredAt, PlayerID: base.PlayerID},
{Event: "state_changed", State: "NOT_A_STATE", Revision: base.Revision, ResourceID: base.ResourceID, OccurredAt: base.OccurredAt, PlayerID: base.PlayerID},
{Event: "proposal_changed", State: "LIVE", Revision: base.Revision, ResourceID: base.ResourceID, OccurredAt: base.OccurredAt, PlayerID: base.PlayerID},
{Event: "assignment_changed", Revision: base.Revision, ResourceID: base.ResourceID, OccurredAt: base.OccurredAt, PlayerID: base.PlayerID},