Files
browser/platform/validator/game_client_bridge.go
T

299 lines
11 KiB
Go

package validator
import (
"encoding/json"
"fmt"
"math"
"strings"
"unicode"
"unicode/utf8"
"browser.local/platform/domain"
)
const (
maxGameClientBridgeArrayItems = 4096
maxGameClientBridgeIdentifierLength = 180
maxGameClientBridgeObjectKeys = 64
maxGameClientBridgePayloadDepth = 16
maxGameClientBridgePayloadNodes = 8192
maxGameClientBridgePayloadSize = 64 * 1024
maxGameClientBridgePayloadString = 16 * 1024
maxGameClientBridgeSessionLength = 4096
)
func ValidateGameClientBridgeQueueRequest(request domain.GameClientBridgeQueueRequest) error {
var violations []string
violations = appendGameClientBridgeIdentifier(violations, "serverInstanceId", request.ServerInstanceID, true)
violations = appendGameClientBridgeIdentifier(violations, "pluginId", request.PluginID, true)
violations = appendGameClientBridgeIdentifier(violations, "profileKey", request.ProfileKey, true)
violations = appendGameClientBridgeIdentifier(violations, "commandType", request.CommandType, true)
violations = appendGameClientBridgeIdentifier(violations, "idempotencyKey", request.IdempotencyKey, true)
if request.ExpiresAt.IsZero() {
violations = append(violations, "expiresAt is required")
}
if request.Priority < 0 || request.Priority > 100 {
violations = append(violations, "priority must be between 0 and 100")
}
violations = append(violations, validateGameClientBridgePayload(request.Payload)...)
return finish(violations)
}
func ValidateGameClientBridgeClaimRequest(request domain.GameClientBridgeClaimRequest) error {
var violations []string
violations = appendGameClientBridgeSession(violations, request.SessionToken)
if request.Limit < 0 || request.Limit > 50 {
violations = append(violations, "limit must be between 0 and 50")
}
return finish(violations)
}
func ValidateGameClientBridgeAckRequest(request domain.GameClientBridgeAckRequest) error {
var violations []string
violations = appendGameClientBridgeSession(violations, request.SessionToken)
violations = appendGameClientBridgeIdentifier(violations, "commandId", request.CommandID, true)
if request.FencingToken == 0 {
violations = append(violations, "fencingToken is required")
}
return finish(violations)
}
func ValidateGameClientBridgeResultRequest(request domain.GameClientBridgeResultRequest) error {
var violations []string
violations = appendGameClientBridgeSession(violations, request.SessionToken)
violations = appendGameClientBridgeIdentifier(violations, "commandId", request.CommandID, true)
if request.FencingToken == 0 {
violations = append(violations, "fencingToken is required")
}
if request.Status != domain.GameClientBridgeResultSucceeded && request.Status != domain.GameClientBridgeResultFailed && request.Status != domain.GameClientBridgeResultUnknown && request.Status != domain.GameClientBridgeResultCancelled {
violations = append(violations, "status is invalid")
}
violations = appendGameClientBridgeText(violations, "summary", request.Summary, 512)
violations = append(violations, validateGameClientBridgePayload(request.Payload)...)
return finish(violations)
}
func ValidateGameClientBridgeCancelRequest(request domain.GameClientBridgeCancelRequest) error {
var violations []string
violations = appendGameClientBridgeIdentifier(violations, "commandId", request.CommandID, true)
violations = appendGameClientBridgeText(violations, "reason", request.Reason, 256)
return finish(violations)
}
func ValidateGameClientBridgeSnapshotIngestRequest(request domain.GameClientBridgeSnapshotIngestRequest) error {
var violations []string
violations = appendGameClientBridgeSession(violations, request.SessionToken)
violations = appendGameClientBridgeIdentifier(violations, "type", request.Type, true)
violations = appendGameClientBridgeIdentifier(violations, "schemaVersion", request.SchemaVersion, true)
violations = appendGameClientBridgeIdentifier(violations, "streamKey", request.StreamKey, true)
if request.Sequence == 0 {
violations = append(violations, "sequence must be positive")
}
if request.ObservedAt.IsZero() {
violations = append(violations, "observedAt is required")
}
if request.Retention.KeepForSeconds <= 0 || request.Retention.KeepForSeconds > 31*24*60*60 {
violations = append(violations, "retention.keepForSeconds must be between 1 and 2678400")
}
if request.Retention.MaxRecords < 0 || request.Retention.MaxRecords > 10000 {
violations = append(violations, "retention.maxRecords must be between 0 and 10000")
}
if request.Payload == nil {
violations = append(violations, "payload is required")
}
violations = append(violations, validateGameClientBridgePayload(request.Payload)...)
return finish(violations)
}
func ValidateGameClientBridgeLogStreamRequest(request domain.GameClientBridgeLogStreamRequest) error {
var violations []string
violations = appendGameClientBridgeSession(violations, request.SessionToken)
return finish(violations)
}
func ValidateGameClientBridgeSnapshotQuery(query domain.GameClientBridgeSnapshotQuery) error {
var violations []string
violations = appendGameClientBridgeIdentifier(violations, "serverInstanceId", query.ServerInstanceID, true)
violations = appendGameClientBridgeIdentifier(violations, "pluginId", query.PluginID, true)
violations = appendGameClientBridgeIdentifier(violations, "profileKey", query.ProfileKey, false)
violations = appendGameClientBridgeIdentifier(violations, "type", query.Type, false)
violations = appendGameClientBridgeIdentifier(violations, "streamKey", query.StreamKey, false)
if query.Limit < 0 || query.Limit > 200 {
violations = append(violations, "limit must be between 0 and 200")
}
return finish(violations)
}
func appendGameClientBridgeIdentifier(violations []string, field, value string, required bool) []string {
if value == "" {
if required {
return append(violations, field+" is required")
}
return violations
}
if !utf8.ValidString(value) || strings.TrimSpace(value) != value || utf8.RuneCountInString(value) > maxGameClientBridgeIdentifierLength {
return append(violations, field+" is invalid")
}
first, _ := utf8.DecodeRuneInString(value)
last, _ := utf8.DecodeLastRuneInString(value)
if !isGameClientBridgeASCIIAlphanumeric(first) || !isGameClientBridgeASCIIAlphanumeric(last) {
return append(violations, field+" is invalid")
}
for _, character := range value {
if !isGameClientBridgeIdentifierCharacter(character) {
return append(violations, field+" is invalid")
}
}
if containsUnsafeRuntimeSecret(value) || looksLikeRawHostPath(value) || strings.Contains(value, "://") || strings.Contains(value, "..") {
return append(violations, field+" is invalid")
}
return violations
}
func isGameClientBridgeASCIIAlphanumeric(character rune) bool {
return character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9'
}
func isGameClientBridgeIdentifierCharacter(character rune) bool {
return character >= 'a' && character <= 'z' ||
character >= 'A' && character <= 'Z' ||
character >= '0' && character <= '9' ||
character == '.' || character == '_' || character == '-' || character == ':'
}
func appendGameClientBridgeSession(violations []string, value string) []string {
if strings.TrimSpace(value) == "" {
return append(violations, "sessionToken is required")
}
if !utf8.ValidString(value) || strings.TrimSpace(value) != value || len(value) > maxGameClientBridgeSessionLength || containsControlCharacter(value) {
return append(violations, "sessionToken is invalid")
}
return violations
}
func appendGameClientBridgeText(violations []string, field, value string, maximum int) []string {
if value == "" {
return violations
}
if !utf8.ValidString(value) || strings.TrimSpace(value) != value || utf8.RuneCountInString(value) > maximum || containsControlCharacter(value) {
violations = append(violations, field+" is invalid")
}
return violations
}
func containsControlCharacter(value string) bool {
for _, character := range value {
if unicode.IsControl(character) {
return true
}
}
return false
}
type gameClientBridgePayloadBudget struct {
nodes int
}
func validateGameClientBridgePayload(payload map[string]any) []string {
if payload == nil {
return nil
}
budget := gameClientBridgePayloadBudget{}
violations := validateGameClientBridgePayloadValue("payload", payload, 0, &budget)
if len(violations) != 0 {
return violations
}
encoded, err := json.Marshal(payload)
if err != nil {
return []string{"payload must be valid JSON"}
}
if len(encoded) > maxGameClientBridgePayloadSize {
return []string{"payload is too large"}
}
return nil
}
func validateGameClientBridgePayloadValue(field string, value any, depth int, budget *gameClientBridgePayloadBudget) []string {
budget.nodes++
if budget.nodes > maxGameClientBridgePayloadNodes {
return []string{"payload has too many values"}
}
if depth > maxGameClientBridgePayloadDepth {
return []string{"payload nesting is too deep"}
}
switch typed := value.(type) {
case nil, bool:
return nil
case string:
return validateGameClientBridgePayloadString(field, typed)
case float64:
if math.IsInf(typed, 0) || math.IsNaN(typed) {
return []string{field + " must be a finite JSON number"}
}
return nil
case float32:
if math.IsInf(float64(typed), 0) || math.IsNaN(float64(typed)) {
return []string{field + " must be a finite JSON number"}
}
return nil
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
return nil
case json.Number:
if _, err := json.Marshal(typed); err != nil {
return []string{field + " must be a valid JSON number"}
}
return nil
case map[string]any:
if len(typed) > maxGameClientBridgeObjectKeys {
return []string{field + " has too many keys"}
}
var violations []string
for key, item := range typed {
if !validGameClientBridgePayloadKey(key) {
violations = append(violations, field+" key "+fmt.Sprintf("%q", key)+" is invalid")
continue
}
violations = append(violations, validateGameClientBridgePayloadValue(field+"."+key, item, depth+1, budget)...)
}
return violations
case []any:
if len(typed) > maxGameClientBridgeArrayItems {
return []string{field + " has too many items"}
}
var violations []string
for index, item := range typed {
violations = append(violations, validateGameClientBridgePayloadValue(fmt.Sprintf("%s[%d]", field, index), item, depth+1, budget)...)
}
return violations
default:
return []string{fmt.Sprintf("%s uses non-JSON type %T", field, value)}
}
}
func validateGameClientBridgePayloadString(field, value string) []string {
var violations []string
if !utf8.ValidString(value) {
violations = append(violations, field+" must be valid UTF-8")
}
if utf8.RuneCountInString(value) > maxGameClientBridgePayloadString {
violations = append(violations, field+" is too long")
}
if containsControlCharacter(value) {
violations = append(violations, field+" contains control characters")
}
return violations
}
func validGameClientBridgePayloadKey(key string) bool {
if key == "" || !utf8.ValidString(key) || strings.TrimSpace(key) != key || utf8.RuneCountInString(key) > 80 {
return false
}
for _, character := range key {
if !isGameClientBridgeIdentifierCharacter(character) || character == ':' {
return false
}
}
return true
}