428 lines
15 KiB
Go
428 lines
15 KiB
Go
package validator
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"regexp"
|
|
"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
|
|
)
|
|
|
|
var (
|
|
gameClientBridgeAcronymBoundary = regexp.MustCompile(`([A-Z]+)([A-Z][a-z])`)
|
|
gameClientBridgeCamelBoundary = regexp.MustCompile(`([a-z0-9])([A-Z])`)
|
|
gameClientBridgeNonWord = regexp.MustCompile(`[^A-Za-z0-9]+`)
|
|
)
|
|
|
|
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 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")
|
|
}
|
|
lowered := strings.ToLower(strings.TrimSpace(value))
|
|
if containsUnsafeRuntimeSecret(value) || looksLikeRawHostPath(value) || hasUnsafeGameClientBridgeReference(lowered) || containsEmbeddedGameClientBridgeHostPath(lowered) {
|
|
violations = append(violations, field+" contains unsafe connection or host material")
|
|
}
|
|
for _, reason := range unsafePluginStringReasons(value) {
|
|
violations = append(violations, field+": "+reason)
|
|
}
|
|
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
|
|
}
|
|
if unsafeGameClientBridgePayloadKey(key) {
|
|
violations = append(violations, field+" contains forbidden key "+key)
|
|
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")
|
|
}
|
|
for _, reason := range unsafePluginStringReasons(value) {
|
|
violations = append(violations, field+": "+reason)
|
|
}
|
|
lowered := strings.ToLower(strings.TrimSpace(value))
|
|
if containsUnsafeRuntimeSecret(value) || hasUnsafeGameClientBridgeReference(lowered) || containsEmbeddedGameClientBridgeHostPath(lowered) {
|
|
violations = append(violations, field+" contains unsafe connection material")
|
|
}
|
|
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
|
|
}
|
|
|
|
func unsafeGameClientBridgePayloadKey(key string) bool {
|
|
tokens := gameClientBridgePayloadKeyTokens(key)
|
|
if len(tokens) == 0 {
|
|
return true
|
|
}
|
|
normalized := strings.Join(tokens, "")
|
|
for _, exact := range []string{
|
|
"absolutepath", "apikey", "commandline", "componentkey", "credential", "credentials", "directsocket", "dsn", "hostpath", "password", "passwd", "rawpath", "rawsql", "runendpoint", "runsocket", "script", "secret", "sessiontoken", "shell", "socket", "sql", "statement", "terminalcommand",
|
|
} {
|
|
if normalized == strings.ReplaceAll(exact, " ", "") {
|
|
return true
|
|
}
|
|
}
|
|
if last := tokens[len(tokens)-1]; last == "password" || last == "passwd" || last == "secret" || last == "credential" || last == "credentials" || last == "dsn" {
|
|
return true
|
|
}
|
|
for _, sequence := range [][]string{
|
|
{"api", "key"},
|
|
{"access", "key"},
|
|
{"private", "key"},
|
|
{"auth", "token"},
|
|
{"access", "token"},
|
|
{"client", "secret"},
|
|
{"storage", "credential"},
|
|
{"component", "key"},
|
|
{"session", "token"},
|
|
{"host", "path"},
|
|
{"raw", "path"},
|
|
{"absolute", "path"},
|
|
{"file", "system", "path"},
|
|
{"direct", "socket"},
|
|
{"socket", "path"},
|
|
{"socket", "address"},
|
|
{"socket", "url"},
|
|
{"socket", "endpoint"},
|
|
{"run", "endpoint"},
|
|
{"run", "url"},
|
|
{"run", "socket"},
|
|
{"run", "token"},
|
|
{"run", "credential"},
|
|
{"raw", "sql"},
|
|
{"raw", "query"},
|
|
{"sql", "text"},
|
|
{"sql", "query"},
|
|
{"sql", "statement"},
|
|
{"arbitrary", "sql"},
|
|
{"shell", "command"},
|
|
{"shell", "script"},
|
|
{"script", "body"},
|
|
{"terminal", "command"},
|
|
{"command", "line"},
|
|
{"arbitrary", "shell"},
|
|
} {
|
|
if gameClientBridgeContainsSensitiveSequence(tokens, sequence) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func containsEmbeddedGameClientBridgeHostPath(value string) bool {
|
|
for _, marker := range []string{"/etc/", "/var/", "/tmp/", "/home/", "/root/", "/private/", "/users/", "/volumes/", "/opt/", `:\\`} {
|
|
if strings.Contains(value, marker) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func gameClientBridgeContainsSensitiveSequence(tokens, sequence []string) bool {
|
|
for start := 0; start+len(sequence) <= len(tokens); start++ {
|
|
matched := true
|
|
for index, expected := range sequence {
|
|
if tokens[start+index] != expected {
|
|
matched = false
|
|
break
|
|
}
|
|
}
|
|
if !matched {
|
|
continue
|
|
}
|
|
end := start + len(sequence)
|
|
if end == len(tokens) {
|
|
return true
|
|
}
|
|
switch tokens[end] {
|
|
case "address", "body", "content", "material", "path", "raw", "ref", "text", "url", "value":
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func gameClientBridgePayloadKeyTokens(key string) []string {
|
|
withAcronymBoundaries := gameClientBridgeAcronymBoundary.ReplaceAllString(key, `${1} ${2}`)
|
|
withCamelBoundaries := gameClientBridgeCamelBoundary.ReplaceAllString(withAcronymBoundaries, `${1} ${2}`)
|
|
return strings.Fields(strings.ToLower(gameClientBridgeNonWord.ReplaceAllString(withCamelBoundaries, " ")))
|
|
}
|
|
|
|
func hasUnsafeGameClientBridgeReference(value string) bool {
|
|
for _, fragment := range []string{
|
|
"unix://", "tcp://", "mysql://", "postgres://", "postgresql://", "mongodb://", "redis://", "sqlite://", "sqlserver://", "mssql://", "odbc:", "secret://", "vault://", "env://", "http://127.", "https://127.", "http://localhost", "https://localhost",
|
|
} {
|
|
if strings.Contains(value, fragment) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|