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
+2
View File
@@ -1565,4 +1565,6 @@ WebSocket event envelopes now require RFC3339 timestamps rather than merely non-
WebSocket event resource identifiers now enforce the contracts opaque 16128 character allowlist, preventing path/separator text or undersized identifiers from entering the client projection. WebSocket event resource identifiers now enforce the contracts opaque 16128 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. 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.
+5 -1
View File
@@ -11,6 +11,7 @@ import (
"io" "io"
"net" "net"
"net/http" "net/http"
"regexp"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -27,8 +28,11 @@ const (
webSocketMessageLimit = 120 webSocketMessageLimit = 120
webSocketMessageWindow = time.Minute webSocketMessageWindow = time.Minute
maxEventConnectionsPerPlayer = 2 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 // ControlPlaneEvent is the server-to-client envelope defined by the v1
// WebSocket contract. PlayerID is routing metadata and is never serialized. // WebSocket contract. PlayerID is routing metadata and is never serialized.
type ControlPlaneEvent struct { type ControlPlaneEvent struct {
@@ -114,7 +118,7 @@ func (h *eventHub) publish(event ControlPlaneEvent) error {
} }
func validateControlPlaneEvent(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") return errors.New("invalid control-plane event envelope")
} }
switch event.Event { switch event.Event {
+5 -5
View File
@@ -17,7 +17,7 @@ func TestDeliverProposalOutboxEventPublishesEveryTarget(t *testing.T) {
defer service.getEventHub().unsubscribe(second) defer service.getEventHub().unsubscribe(second)
payload, err := json.Marshal(map[string]any{ 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"}, "occurred_at": time.Unix(1000, 0).UTC(), "state": "OPEN", "player_ids": []string{"player-a", "player-b"},
}) })
if err != nil { if err != nil {
@@ -66,8 +66,8 @@ func TestDeliverStateOutboxEventValidatesRevisionAndTargets(t *testing.T) {
service := &Service{} service := &Service{}
first := service.getEventHub().subscribe("player-a") first := service.getEventHub().subscribe("player-a")
defer service.getEventHub().unsubscribe(first) 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"]}`) 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-1", Revision: 4, Payload: payload}, service); err != nil { 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) t.Fatalf("valid state event rejected: %v", err)
} }
select { select {
@@ -75,8 +75,8 @@ func TestDeliverStateOutboxEventValidatesRevisionAndTargets(t *testing.T) {
case <-time.After(time.Second): case <-time.After(time.Second):
t.Fatal("participant did not receive state event") 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"]}`) 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-1", Revision: 4, Payload: bad}, service); err == nil { 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") 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"} base := ControlPlaneEvent{Revision: 1, ResourceID: "ticket-1234567890123456", OccurredAt: time.Unix(1000, 0).UTC(), PlayerID: "player-1"}
invalid := []ControlPlaneEvent{ invalid := []ControlPlaneEvent{
{Event: "unknown", Revision: base.Revision, ResourceID: base.ResourceID, OccurredAt: base.OccurredAt, PlayerID: base.PlayerID}, {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: "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: "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}, {Event: "assignment_changed", Revision: base.Revision, ResourceID: base.ResourceID, OccurredAt: base.OccurredAt, PlayerID: base.PlayerID},