diff --git a/multiplayer-next.md b/multiplayer-next.md index c4762c42..6d5de3a4 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1565,4 +1565,6 @@ WebSocket event envelopes now require RFC3339 timestamps rather than merely non- WebSocket event resource identifiers now enforce the contract’s opaque 16–128 character allowlist, preventing path/separator text or undersized identifiers from entering the client projection. +The Go event hub now enforces the same resource-ID allowlist before publication, so malformed identifiers are rejected at the server boundary rather than only discarded by clients. + The matchmaking UI now displays the authoritative proposal countdown from the server expiry epoch, clamped at zero and retaining compatible copy when older responses omit expiry metadata. Adversarial countdown tests cover delayed and missing-expiry responses. diff --git a/server/api/events.go b/server/api/events.go index d99bb382..c3a844d5 100644 --- a/server/api/events.go +++ b/server/api/events.go @@ -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 { diff --git a/server/api/outbox_test.go b/server/api/outbox_test.go index 04311ea8..0ae840b1 100644 --- a/server/api/outbox_test.go +++ b/server/api/outbox_test.go @@ -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") } } diff --git a/server/api/service_test.go b/server/api/service_test.go index f2e1af2f..f5b2f975 100644 --- a/server/api/service_test.go +++ b/server/api/service_test.go @@ -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},