功能修改
This commit is contained in:
@@ -154,6 +154,68 @@ func ValidateClientManagerLifecycleInputRequest(value domain.ClientManagerLifecy
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateClientManagerCompanionConfigInput(value domain.ClientManagerCompanionConfigInput) error {
|
||||
var violations []string
|
||||
if value.SchemaVersion != domain.ClientManagerCompanionConfigSchemaVersion {
|
||||
violations = append(violations, "schemaVersion is invalid")
|
||||
}
|
||||
for field, content := range map[string]string{
|
||||
"configTemplateKey": value.ConfigTemplateKey, "configTemplateRef": value.ConfigTemplateRef, "configOutputRef": value.ConfigOutputRef,
|
||||
"configSchemaRef": value.ConfigSchemaRef, "installationId": value.InstallationID, "serverInstanceId": value.ServerInstanceID,
|
||||
"pluginId": value.PluginID, "profileKey": value.ProfileKey, "artifactId": value.ArtifactID, "version": value.Version,
|
||||
"sourceRevision": value.SourceRevision, "targetOs": value.TargetOS, "targetArch": value.TargetArch, "proofMaterialEnv": value.ProofMaterialEnv,
|
||||
} {
|
||||
violations = appendRequired(violations, field, content)
|
||||
}
|
||||
if !clientManagerIdentifierPattern.MatchString(value.ConfigTemplateKey) {
|
||||
violations = append(violations, "configTemplateKey is invalid")
|
||||
}
|
||||
violations = append(violations, validateSafeRelativeRuntimePath("configTemplateRef", value.ConfigTemplateRef)...)
|
||||
if value.ConfigOutputRef != "config.yaml" {
|
||||
violations = append(violations, "configOutputRef must be config.yaml")
|
||||
}
|
||||
if !safeRelativeJSONRef(value.ConfigSchemaRef) {
|
||||
violations = append(violations, "configSchemaRef must be a safe relative JSON reference")
|
||||
}
|
||||
if value.ConfigFormat != "yaml" || value.PlatformBaseURLSource != "run-control" || value.RegistrationProof != "hmac-sha256" || value.ProofMaterialSource != "component-package" || value.SessionMode != "component-session" || value.TLSPolicy != "verify-system-roots" {
|
||||
violations = append(violations, "companion bootstrap security policy is invalid")
|
||||
}
|
||||
if !validCompanionProofEnvironment(value.ProofMaterialEnv) {
|
||||
violations = append(violations, "proofMaterialEnv is invalid")
|
||||
}
|
||||
for field, identifier := range map[string]string{"installationId": value.InstallationID, "serverInstanceId": value.ServerInstanceID, "pluginId": value.PluginID, "profileKey": value.ProfileKey, "artifactId": value.ArtifactID} {
|
||||
if !clientManagerIdentifierPattern.MatchString(identifier) {
|
||||
violations = append(violations, field+" is invalid")
|
||||
}
|
||||
}
|
||||
violations = appendDistributionTargetViolations(violations, value.TargetOS, value.TargetArch)
|
||||
if value.KeyGeneration <= 0 || value.DeploymentGeneration <= 0 {
|
||||
violations = append(violations, "keyGeneration and deploymentGeneration must be positive")
|
||||
}
|
||||
if len(value.Capabilities) == 0 || len(value.Capabilities) > 32 {
|
||||
violations = append(violations, "capabilities must be bounded")
|
||||
}
|
||||
capabilities := make(map[string]struct{}, len(value.Capabilities))
|
||||
for _, capability := range value.Capabilities {
|
||||
if !clientManagerIdentifierPattern.MatchString(capability) {
|
||||
violations = append(violations, "capability is invalid")
|
||||
}
|
||||
if _, exists := capabilities[capability]; exists {
|
||||
violations = append(violations, "capabilities must be unique")
|
||||
}
|
||||
capabilities[capability] = struct{}{}
|
||||
}
|
||||
for _, required := range []string{"component.register", "component.heartbeat", "component.health", "game-client.bridge"} {
|
||||
if _, exists := capabilities[required]; !exists {
|
||||
violations = append(violations, "capabilities must include "+required)
|
||||
}
|
||||
}
|
||||
if value.HeartbeatIntervalSeconds < 5 || value.HeartbeatIntervalSeconds > 300 || value.CommandPollIntervalSeconds < 1 || value.CommandPollIntervalSeconds > 60 || value.RequestTimeoutSeconds < 1 || value.RequestTimeoutSeconds > 60 {
|
||||
violations = append(violations, "companion timing policy is invalid")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateClientManagerRegisterRequest(value domain.ClientManagerRegisterRequest) error {
|
||||
var violations []string
|
||||
for field, content := range map[string]string{"installationId": value.InstallationID, "serverInstanceId": value.ServerInstanceID, "profileKey": value.ProfileKey, "artifactId": value.ArtifactID, "version": value.Version, "sourceRevision": value.SourceRevision, "targetOs": value.TargetOS, "targetArch": value.TargetArch, "nonce": value.Nonce, "signature": value.Signature} {
|
||||
|
||||
@@ -60,12 +60,17 @@ func ValidateRunControlHeartbeat(heartbeat domain.RunControlHeartbeat) error {
|
||||
}
|
||||
|
||||
func appendCapacityViolations(violations []string, capacity domain.RunCapacity) []string {
|
||||
if capacity.MaxJobs < 0 || capacity.RunningJobs < 0 || capacity.QueuedJobs < 0 {
|
||||
if capacity.MaxJobs < 0 || capacity.RunningJobs < 0 || capacity.QueuedJobs < 0 || capacity.LogBacklogBatches < 0 || capacity.ArtifactBacklogChunks < 0 {
|
||||
violations = append(violations, "capacity counts must not be negative")
|
||||
}
|
||||
if capacity.MaxJobs > 0 && capacity.RunningJobs > capacity.MaxJobs {
|
||||
violations = append(violations, "runningJobs must not exceed maxJobs")
|
||||
}
|
||||
for i, code := range capacity.PressureCodes {
|
||||
if strings.TrimSpace(code) == "" || strings.ContainsAny(code, " \t\r\n") || containsUnsafeRuntimeSecret(code) || looksLikeRawHostPath(code) {
|
||||
violations = append(violations, fmt.Sprintf("pressureCodes[%d] is invalid", i))
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
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.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
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func validGameClientBridgeCompanionManifest() (domain.GameClientBridgeManifest, domain.GamePluginRuntimeProfiles) {
|
||||
bridge := domain.GameClientBridgeManifest{
|
||||
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000},
|
||||
Companion: domain.GameClientBridgeCompanionDeclaration{
|
||||
ProfileKey: "scum-client-manager",
|
||||
ConfigTemplateKey: "client-config",
|
||||
ConfigSchemaRef: "schemas/companion/config.schema.json",
|
||||
ConfigFormat: "yaml",
|
||||
PlatformBaseURLSource: "run-control",
|
||||
RegistrationProof: "hmac-sha256",
|
||||
ProofMaterialSource: "component-package",
|
||||
ProofMaterialEnv: "SCUM_COMPONENT_PROOF",
|
||||
SessionMode: "component-session",
|
||||
TLSPolicy: "verify-system-roots",
|
||||
HeartbeatIntervalSeconds: 30,
|
||||
CommandPollIntervalSeconds: 5,
|
||||
RequestTimeoutSeconds: 15,
|
||||
},
|
||||
}
|
||||
profiles := domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{{
|
||||
Key: "scum-client-manager",
|
||||
ConfigTemplates: []domain.RuntimeConfigTemplate{{Key: "client-config", TemplateRef: "config.yaml.example", OutputRef: "config.yaml"}},
|
||||
Health: domain.RuntimeClientManagerHealth{IntervalSeconds: 30, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health", "game-client.bridge"}},
|
||||
}}}
|
||||
return bridge, profiles
|
||||
}
|
||||
|
||||
func TestValidateGameClientBridgeCompanionDeclaration(t *testing.T) {
|
||||
bridge, profiles := validGameClientBridgeCompanionManifest()
|
||||
if violations := validateGameClientBridgeManifest("gameClientBridge", bridge, nil, nil, profiles); len(violations) != 0 {
|
||||
t.Fatalf("expected valid companion declaration, got %v", violations)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expected string
|
||||
mutate func(*domain.GameClientBridgeManifest, *domain.GamePluginRuntimeProfiles)
|
||||
}{
|
||||
{name: "undeclared profile", expected: "profileKey must reference", mutate: func(bridge *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||
bridge.Companion.ProfileKey = "missing"
|
||||
}},
|
||||
{name: "undeclared template", expected: "configTemplateKey must reference", mutate: func(bridge *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||
bridge.Companion.ConfigTemplateKey = "missing"
|
||||
}},
|
||||
{name: "unsafe schema", expected: "configSchemaRef", mutate: func(bridge *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||
bridge.Companion.ConfigSchemaRef = "/etc/config.json"
|
||||
}},
|
||||
{name: "insecure tls", expected: "security policy", mutate: func(bridge *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||
bridge.Companion.TLSPolicy = "skip-verification"
|
||||
}},
|
||||
{name: "heartbeat mismatch", expected: "must match", mutate: func(bridge *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||
bridge.Companion.HeartbeatIntervalSeconds = 31
|
||||
}},
|
||||
{name: "missing bridge capability", expected: "game-client.bridge", mutate: func(_ *domain.GameClientBridgeManifest, profiles *domain.GamePluginRuntimeProfiles) {
|
||||
profiles.ClientManagers[0].Health.RequiredCapabilities = []string{"component.register", "component.heartbeat", "component.health"}
|
||||
}},
|
||||
{name: "partial declaration", expected: "profile or config template key", mutate: func(bridge *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||
bridge.Companion.ProfileKey = ""
|
||||
}},
|
||||
{name: "reserved proof environment", expected: "proofMaterialEnv", mutate: func(bridge *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
||||
bridge.Companion.ProofMaterialEnv = "LD_PRELOAD"
|
||||
}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidateBridge, candidateProfiles := validGameClientBridgeCompanionManifest()
|
||||
test.mutate(&candidateBridge, &candidateProfiles)
|
||||
violations := validateGameClientBridgeManifest("gameClientBridge", candidateBridge, nil, nil, candidateProfiles)
|
||||
if !strings.Contains(strings.Join(violations, "; "), test.expected) {
|
||||
t.Fatalf("expected %q violation, got %v", test.expected, violations)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateClientManagerCompanionConfigInputFailsClosed(t *testing.T) {
|
||||
valid := domain.ClientManagerCompanionConfigInput{
|
||||
SchemaVersion: domain.ClientManagerCompanionConfigSchemaVersion,
|
||||
ConfigTemplateKey: "client-config",
|
||||
ConfigTemplateRef: "config.yaml.example",
|
||||
ConfigOutputRef: "config.yaml",
|
||||
ConfigSchemaRef: "schemas/companion/config.schema.json",
|
||||
ConfigFormat: "yaml",
|
||||
PlatformBaseURLSource: "run-control",
|
||||
InstallationID: "installation-1",
|
||||
ServerInstanceID: "server-1",
|
||||
PluginID: "game.scum",
|
||||
ProfileKey: "scum-client-manager",
|
||||
ArtifactID: "artifact-1",
|
||||
Version: "1.0.0",
|
||||
SourceRevision: "revision-1",
|
||||
TargetOS: "windows",
|
||||
TargetArch: "amd64",
|
||||
KeyGeneration: 1,
|
||||
DeploymentGeneration: 2,
|
||||
Capabilities: []string{"component.register", "component.heartbeat", "component.health", "game-client.bridge"},
|
||||
RegistrationProof: "hmac-sha256",
|
||||
ProofMaterialSource: "component-package",
|
||||
ProofMaterialEnv: "SCUM_COMPONENT_PROOF",
|
||||
SessionMode: "component-session",
|
||||
TLSPolicy: "verify-system-roots",
|
||||
HeartbeatIntervalSeconds: 30,
|
||||
CommandPollIntervalSeconds: 5,
|
||||
RequestTimeoutSeconds: 15,
|
||||
}
|
||||
if err := ValidateClientManagerCompanionConfigInput(valid); err != nil {
|
||||
t.Fatalf("expected valid companion input, got %v", err)
|
||||
}
|
||||
for name, mutate := range map[string]func(*domain.ClientManagerCompanionConfigInput){
|
||||
"insecure tls": func(value *domain.ClientManagerCompanionConfigInput) { value.TLSPolicy = "skip-verification" },
|
||||
"legacy session": func(value *domain.ClientManagerCompanionConfigInput) { value.SessionMode = "shared-token" },
|
||||
"reserved env": func(value *domain.ClientManagerCompanionConfigInput) { value.ProofMaterialEnv = "PATH" },
|
||||
"unsafe template": func(value *domain.ClientManagerCompanionConfigInput) { value.ConfigTemplateRef = "../config.yaml" },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
candidate := domain.CopyClientManagerCompanionConfigInput(valid)
|
||||
mutate(&candidate)
|
||||
if err := ValidateClientManagerCompanionConfigInput(candidate); err == nil {
|
||||
t.Fatalf("expected invalid companion input: %+v", candidate)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func validBridgeQueueRequest() domain.GameClientBridgeQueueRequest {
|
||||
return domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "announcement.send", Payload: map[string]any{"message": "hello"}, IdempotencyKey: "announce-1", ExpiresAt: time.Now().UTC().Add(time.Minute)}
|
||||
}
|
||||
|
||||
func TestValidateGameClientBridgeRequests(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
checks := map[string]error{
|
||||
"queue": ValidateGameClientBridgeQueueRequest(validBridgeQueueRequest()),
|
||||
"claim": ValidateGameClientBridgeClaimRequest(domain.GameClientBridgeClaimRequest{SessionToken: "session", Limit: 50}),
|
||||
"ack": ValidateGameClientBridgeAckRequest(domain.GameClientBridgeAckRequest{SessionToken: "session", CommandID: "command-1", FencingToken: 1}),
|
||||
"result": ValidateGameClientBridgeResultRequest(domain.GameClientBridgeResultRequest{SessionToken: "session", CommandID: "command-1", FencingToken: 1, Status: domain.GameClientBridgeResultSucceeded, Summary: "completed"}),
|
||||
"cancel": ValidateGameClientBridgeCancelRequest(domain.GameClientBridgeCancelRequest{CommandID: "command-1", Reason: "operator request"}),
|
||||
"snapshot": ValidateGameClientBridgeSnapshotIngestRequest(domain.GameClientBridgeSnapshotIngestRequest{SessionToken: "session", Type: "players", SchemaVersion: "1.2.0", StreamKey: "current", Sequence: 1, ObservedAt: now, Payload: map[string]any{"players": []any{}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600}}),
|
||||
"query": ValidateGameClientBridgeSnapshotQuery(domain.GameClientBridgeSnapshotQuery{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", StreamKey: "current", Limit: 200}),
|
||||
}
|
||||
for name, err := range checks {
|
||||
if err != nil {
|
||||
t.Fatalf("validate %s request: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGameClientBridgeRequestFieldBounds(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{name: "queue identifier", err: func() error {
|
||||
request := validBridgeQueueRequest()
|
||||
request.CommandType = "bad/type"
|
||||
return ValidateGameClientBridgeQueueRequest(request)
|
||||
}(), want: "commandType"},
|
||||
{name: "queue priority", err: func() error {
|
||||
request := validBridgeQueueRequest()
|
||||
request.Priority = 101
|
||||
return ValidateGameClientBridgeQueueRequest(request)
|
||||
}(), want: "priority"},
|
||||
{name: "claim token", err: ValidateGameClientBridgeClaimRequest(domain.GameClientBridgeClaimRequest{SessionToken: " session"}), want: "sessionToken"},
|
||||
{name: "claim limit", err: ValidateGameClientBridgeClaimRequest(domain.GameClientBridgeClaimRequest{SessionToken: "session", Limit: 51}), want: "limit"},
|
||||
{name: "ack fence", err: ValidateGameClientBridgeAckRequest(domain.GameClientBridgeAckRequest{SessionToken: "session", CommandID: "command-1"}), want: "fencingToken"},
|
||||
{name: "result state", err: ValidateGameClientBridgeResultRequest(domain.GameClientBridgeResultRequest{SessionToken: "session", CommandID: "command-1", FencingToken: 1, Status: "unknown"}), want: "status"},
|
||||
{name: "result text", err: ValidateGameClientBridgeResultRequest(domain.GameClientBridgeResultRequest{SessionToken: "session", CommandID: "command-1", FencingToken: 1, Status: domain.GameClientBridgeResultFailed, Summary: "read /etc/passwd"}), want: "unsafe"},
|
||||
{name: "cancel text", err: ValidateGameClientBridgeCancelRequest(domain.GameClientBridgeCancelRequest{CommandID: "command-1", Reason: "Bearer private"}), want: "unsafe"},
|
||||
{name: "snapshot payload", err: ValidateGameClientBridgeSnapshotIngestRequest(domain.GameClientBridgeSnapshotIngestRequest{SessionToken: "session", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: time.Now(), Retention: domain.GameClientBridgeRetention{KeepForSeconds: 1}}), want: "payload"},
|
||||
{name: "snapshot sequence", err: ValidateGameClientBridgeSnapshotIngestRequest(domain.GameClientBridgeSnapshotIngestRequest{SessionToken: "session", Type: "players", SchemaVersion: "1", StreamKey: "current", ObservedAt: time.Now(), Payload: map[string]any{}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 1}}), want: "sequence"},
|
||||
{name: "snapshot retention", err: ValidateGameClientBridgeSnapshotIngestRequest(domain.GameClientBridgeSnapshotIngestRequest{SessionToken: "session", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: time.Now(), Payload: map[string]any{}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 31*24*60*60 + 1}}), want: "keepForSeconds"},
|
||||
{name: "query field", err: ValidateGameClientBridgeSnapshotQuery(domain.GameClientBridgeSnapshotQuery{ServerInstanceID: "server-1", PluginID: "game.scum", StreamKey: "tcp://host"}), want: "streamKey"},
|
||||
{name: "query limit", err: ValidateGameClientBridgeSnapshotQuery(domain.GameClientBridgeSnapshotQuery{ServerInstanceID: "server-1", PluginID: "game.scum", Limit: 201}), want: "limit"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if test.err == nil || !strings.Contains(test.err.Error(), test.want) {
|
||||
t.Fatalf("expected %q violation, got %v", test.want, test.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGameClientBridgePayloadAcceptsJSONValuesWithoutKeyFalsePositives(t *testing.T) {
|
||||
request := validBridgeQueueRequest()
|
||||
request.Payload = map[string]any{
|
||||
"nullValue": nil, "enabled": true, "count": int(2), "ratio": float64(1.5), "sequence": json.Number("9007199254740993"), "nested": []any{map[string]any{"value": "safe"}},
|
||||
"secretaryName": "Mina", "socketCount": 2, "apiKeyEnabled": false, "sqlQueryTemplateKey": "players.by-id", "shellStatus": "unavailable", "logicalPath": "snapshots.current", "monkey": "ordinary", "chatMessage": "Select your reward from the list",
|
||||
}
|
||||
if err := ValidateGameClientBridgeQueueRequest(request); err != nil {
|
||||
t.Fatalf("valid JSON payload with benign keys rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGameClientBridgePayloadRejectsUnsafeKeyPatterns(t *testing.T) {
|
||||
keys := []string{"sessionToken", "authToken", "accessKey", "privateKey", "component-key", "databasePassword", "clientSecret", "api_key", "databaseDSN", "storageCredential", "hostPath", "absolute_path", "directSocket", "socketAddress", "runEndpoint", "runUrl", "rawSQL", "rawQuery", "sqlText", "sql_statement", "shellCommand", "shell_script", "scriptBody", "terminalCommand", "commandLine"}
|
||||
for _, key := range keys {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
request := validBridgeQueueRequest()
|
||||
request.Payload = map[string]any{key: "value"}
|
||||
err := ValidateGameClientBridgeQueueRequest(request)
|
||||
if err == nil || !strings.Contains(err.Error(), "forbidden key") {
|
||||
t.Fatalf("expected forbidden key rejection, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGameClientBridgePayloadRejectsUnsafeStringMaterial(t *testing.T) {
|
||||
values := []string{"/etc/passwd", "prefix path=/var/run/run.sock", `C:\\Users\\operator\\secret.txt`, "tcp://127.0.0.1:9000", "unix:///var/run/run.sock", "http://localhost:9000", "mysql://user:password@host/db", "secret://component/key", "vault://runtime/token", "Bearer abc123", "password=leak"}
|
||||
for index, value := range values {
|
||||
request := validBridgeQueueRequest()
|
||||
request.IdempotencyKey = fmt.Sprintf("case-%d", index)
|
||||
request.Payload = map[string]any{"value": value}
|
||||
if err := ValidateGameClientBridgeQueueRequest(request); err == nil || !strings.Contains(err.Error(), "payload") {
|
||||
t.Fatalf("expected unsafe value %q rejection, got %v", value, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGameClientBridgePayloadRejectsNonJSONValuesAndInvalidNumbers(t *testing.T) {
|
||||
tests := map[string]any{"typed map": map[string]string{"key": "value"}, "typed slice": []string{"value"}, "time": time.Now(), "channel": make(chan int), "not a number": math.NaN(), "infinity": math.Inf(1), "invalid number": json.Number("01")}
|
||||
for name, value := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
request := validBridgeQueueRequest()
|
||||
request.Payload = map[string]any{"value": value}
|
||||
if err := ValidateGameClientBridgeQueueRequest(request); err == nil {
|
||||
t.Fatalf("expected %s rejection", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGameClientBridgePayloadStructuralBudgets(t *testing.T) {
|
||||
tests := map[string]map[string]any{}
|
||||
tooManyKeys := map[string]any{}
|
||||
for index := 0; index < maxGameClientBridgeObjectKeys+1; index++ {
|
||||
tooManyKeys[fmt.Sprintf("key%d", index)] = index
|
||||
}
|
||||
tests["object keys"] = tooManyKeys
|
||||
deep := map[string]any{"value": true}
|
||||
for index := 0; index < maxGameClientBridgePayloadDepth+2; index++ {
|
||||
deep = map[string]any{"nested": deep}
|
||||
}
|
||||
tests["depth"] = deep
|
||||
tests["array items"] = map[string]any{"items": make([]any, maxGameClientBridgeArrayItems+1)}
|
||||
tests["string"] = map[string]any{"message": strings.Repeat("x", maxGameClientBridgePayloadString+1)}
|
||||
tests["encoded size"] = map[string]any{"one": strings.Repeat("x", 15000), "two": strings.Repeat("x", 15000), "three": strings.Repeat("x", 15000), "four": strings.Repeat("x", 15000), "five": strings.Repeat("x", 15000)}
|
||||
tests["invalid key"] = map[string]any{"bad key": true}
|
||||
tests["invalid utf8"] = map[string]any{"value": string([]byte{0xff})}
|
||||
wide := map[string]any{}
|
||||
for index := 0; index < 64; index++ {
|
||||
wide[fmt.Sprintf("items%d", index)] = make([]any, 128)
|
||||
}
|
||||
tests["node budget"] = wide
|
||||
cycle := map[string]any{}
|
||||
cycle["self"] = cycle
|
||||
tests["cycle"] = cycle
|
||||
|
||||
for name, payload := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
request := validBridgeQueueRequest()
|
||||
request.Payload = payload
|
||||
if err := ValidateGameClientBridgeQueueRequest(request); err == nil {
|
||||
t.Fatalf("expected %s rejection", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -96,5 +96,28 @@ func ValidateRemoteAdapterRequest(request domain.RemoteAdapterRequest) error {
|
||||
if strings.ContainsAny(request.TargetKey, "\\\n\r") || strings.Contains(request.TargetKey, "://") || strings.ContainsAny(request.TargetKey, " ") {
|
||||
violations = append(violations, "targetKey must be a logical key")
|
||||
}
|
||||
if request.InputRef != "" && !validScopedInputRef(request.InputRef) {
|
||||
violations = append(violations, "inputRef is not allowed")
|
||||
}
|
||||
violations = append(violations, validateRemoteAdapterInputs("inputs", request.Inputs)...)
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func validateRemoteAdapterInputs(field string, inputs map[string]string) []string {
|
||||
if len(inputs) > 32 {
|
||||
return []string{field + " has too many values"}
|
||||
}
|
||||
var violations []string
|
||||
for key, value := range inputs {
|
||||
if !clientManagerIdentifierPattern.MatchString(key) || unsafeGameClientBridgePayloadKey(key) {
|
||||
violations = append(violations, field+" key is invalid or unsafe")
|
||||
}
|
||||
if len([]rune(value)) > 2048 {
|
||||
violations = append(violations, field+"."+key+" is too long")
|
||||
}
|
||||
for _, reason := range unsafePluginStringReasons(value) {
|
||||
violations = append(violations, field+"."+key+": "+reason)
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func ValidateCapacityAdmissionRequest(request domain.CapacityAdmissionRequest) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "capability", request.Capability)
|
||||
if request.ServerInstanceID != "" && !safeIdentifier(request.ServerInstanceID) {
|
||||
violations = append(violations, "serverInstanceId is invalid")
|
||||
}
|
||||
if request.RunEndpointID != "" && !safeIdentifier(request.RunEndpointID) {
|
||||
violations = append(violations, "runEndpointId is invalid")
|
||||
}
|
||||
if request.TargetKey != "" && !validLogicalFileKey(request.TargetKey) {
|
||||
violations = append(violations, "targetKey is invalid")
|
||||
}
|
||||
if unsafeProductionText(request.Capability) || unsafeProductionText(request.IdempotencyKey) {
|
||||
violations = append(violations, "capacity request contains unsafe content")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateCapacityAdmissionDecision(decision domain.CapacityAdmissionDecision) error {
|
||||
var violations []string
|
||||
if !validCapacityAdmissionState(decision.State) {
|
||||
violations = append(violations, "state is invalid")
|
||||
}
|
||||
violations = appendRequired(violations, "reason", decision.Reason)
|
||||
if len(decision.Reason) > maxProductionMessageLength || unsafeProductionText(decision.Reason) {
|
||||
violations = append(violations, "reason is unsafe")
|
||||
}
|
||||
for i, code := range decision.PressureCodes {
|
||||
if !validCapacityPressureCode(code) {
|
||||
violations = append(violations, fmt.Sprintf("pressureCodes[%d] is invalid", i))
|
||||
}
|
||||
}
|
||||
if decision.RunningJobs < 0 || decision.QueuedJobs < 0 || decision.MaxJobs < 0 {
|
||||
violations = append(violations, "capacity counts must not be negative")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateAlertRecord(alert domain.AlertRecord) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "id", alert.ID)
|
||||
violations = appendRequired(violations, "sourceKind", alert.SourceKind)
|
||||
violations = appendRequired(violations, "sourceId", alert.SourceID)
|
||||
violations = appendRequired(violations, "ruleKey", alert.RuleKey)
|
||||
violations = appendRequired(violations, "title", alert.Title)
|
||||
violations = appendRequired(violations, "message", alert.Message)
|
||||
if !validAlertSeverity(alert.Severity) {
|
||||
violations = append(violations, "severity is invalid")
|
||||
}
|
||||
if !validAlertState(alert.State) {
|
||||
violations = append(violations, "state is invalid")
|
||||
}
|
||||
if alert.OccurrenceCount <= 0 {
|
||||
violations = append(violations, "occurrenceCount must be positive")
|
||||
}
|
||||
for _, value := range []fieldString{{field: "title", value: alert.Title}, {field: "message", value: alert.Message}, {field: "resolutionNote", value: alert.ResolutionNote}} {
|
||||
if len(value.value) > maxProductionMessageLength || unsafeProductionText(value.value) {
|
||||
violations = append(violations, value.field+" is unsafe")
|
||||
}
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateAlertAcknowledgeRequest(request domain.AlertAcknowledgeRequest) error {
|
||||
return validateAlertNoteRequest(request.AlertID, request.Note)
|
||||
}
|
||||
|
||||
func ValidateAlertResolveRequest(request domain.AlertResolveRequest) error {
|
||||
return validateAlertNoteRequest(request.AlertID, request.Note)
|
||||
}
|
||||
|
||||
func ValidateAlertRetryRequest(request domain.AlertRetryRequest) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "alertId", request.AlertID)
|
||||
violations = appendRequired(violations, "idempotencyKey", request.IdempotencyKey)
|
||||
if unsafeProductionText(request.AlertID) || unsafeProductionText(request.IdempotencyKey) {
|
||||
violations = append(violations, "alert retry request is unsafe")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidatePluginLifecycleInstallation(installation domain.PluginLifecycleInstallation) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "id", installation.ID)
|
||||
violations = appendRequired(violations, "pluginId", installation.PluginID)
|
||||
violations = appendRequired(violations, "serverInstanceId", installation.ServerInstanceID)
|
||||
if !validPluginLifecycleState(installation.DesiredState) {
|
||||
violations = append(violations, "desiredState is invalid")
|
||||
}
|
||||
if !validPluginLifecycleState(installation.CurrentState) {
|
||||
violations = append(violations, "currentState is invalid")
|
||||
}
|
||||
if installation.LastOperation != "" && !validPluginLifecycleOperation(installation.LastOperation) {
|
||||
violations = append(violations, "lastOperation is invalid")
|
||||
}
|
||||
for _, value := range []fieldString{{field: "compatibility", value: installation.Compatibility}, {field: "failureReason", value: installation.FailureReason}} {
|
||||
if len(value.value) > maxProductionMessageLength || unsafeProductionText(value.value) {
|
||||
violations = append(violations, value.field+" is unsafe")
|
||||
}
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidatePluginLifecycleRequest(request domain.PluginLifecycleRequest) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "pluginId", request.PluginID)
|
||||
violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID)
|
||||
violations = appendRequired(violations, "idempotencyKey", request.IdempotencyKey)
|
||||
if !validPluginLifecycleOperation(request.Operation) {
|
||||
violations = append(violations, "operation is invalid")
|
||||
}
|
||||
if (request.Operation == domain.PluginLifecycleOperationDisable || request.Operation == domain.PluginLifecycleOperationRollback || request.Operation == domain.PluginLifecycleOperationRetire) && !request.Confirmed {
|
||||
violations = append(violations, "confirmed is required for disruptive plugin lifecycle operation")
|
||||
}
|
||||
for _, value := range []fieldString{{field: "targetVersion", value: request.TargetVersion}, {field: "idempotencyKey", value: request.IdempotencyKey}} {
|
||||
if unsafeProductionText(value.value) {
|
||||
violations = append(violations, value.field+" is unsafe")
|
||||
}
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateAIConfigDiffPreview(preview domain.AIConfigDiffPreview) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "id", preview.ID)
|
||||
violations = appendRequired(violations, "requestId", preview.RequestID)
|
||||
violations = appendRequired(violations, "createdBy", preview.CreatedBy)
|
||||
violations = appendRequired(violations, "serverInstanceId", preview.ServerInstanceID)
|
||||
violations = appendRequired(violations, "key", preview.Key)
|
||||
violations = appendRequired(violations, "diffSummary", preview.DiffSummary)
|
||||
if preview.ConfigVersion <= 0 {
|
||||
violations = append(violations, "configVersion must be positive")
|
||||
}
|
||||
if !validAIConfigDiffState(preview.State) {
|
||||
violations = append(violations, "state is invalid")
|
||||
}
|
||||
if !validLogicalFileKey(preview.Key) || !validConfigFileKey(preview.Key) {
|
||||
violations = append(violations, "key is invalid")
|
||||
}
|
||||
if len([]byte(preview.ProposedConfig)) > maxServerConfigContentSize || containsUnsafeRuntimeSecret(preview.ProposedConfig) || looksLikeRawHostPath(preview.ProposedConfig) {
|
||||
violations = append(violations, "proposedConfig is unsafe")
|
||||
}
|
||||
if len(preview.DiffSummary) > maxProductionMessageLength || unsafeProductionText(preview.DiffSummary) {
|
||||
violations = append(violations, "diffSummary is unsafe")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func ValidateAIConfigDiffApprovalRequest(request domain.AIConfigDiffApprovalRequest) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "diffId", request.DiffID)
|
||||
violations = appendRequired(violations, "idempotencyKey", request.IdempotencyKey)
|
||||
if unsafeProductionText(request.DiffID) || unsafeProductionText(request.IdempotencyKey) {
|
||||
violations = append(violations, "AI config approval request is unsafe")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func validateAlertNoteRequest(alertID string, note string) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "alertId", alertID)
|
||||
if unsafeProductionText(alertID) || len(note) > maxProductionMessageLength || unsafeProductionText(note) {
|
||||
violations = append(violations, "alert note request is unsafe")
|
||||
}
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func validCapacityAdmissionState(state domain.CapacityAdmissionState) bool {
|
||||
switch state {
|
||||
case domain.CapacityAdmissionAccepted, domain.CapacityAdmissionDeferred, domain.CapacityAdmissionDenied:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validCapacityPressureCode(code domain.CapacityPressureCode) bool {
|
||||
switch code {
|
||||
case domain.CapacityPressureEndpointOffline, domain.CapacityPressureEndpointStale, domain.CapacityPressureCapabilityGap, domain.CapacityPressureJobLimit, domain.CapacityPressureQueueLimit, domain.CapacityPressureBacklog:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validAlertSeverity(severity domain.AlertSeverity) bool {
|
||||
switch severity {
|
||||
case domain.AlertSeverityInfo, domain.AlertSeverityWarning, domain.AlertSeverityCritical:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validAlertState(state domain.AlertState) bool {
|
||||
switch state {
|
||||
case domain.AlertStateActive, domain.AlertStateAcknowledged, domain.AlertStateResolved:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validPluginLifecycleState(state domain.PluginLifecycleState) bool {
|
||||
switch state {
|
||||
case domain.PluginLifecycleStatePending, domain.PluginLifecycleStateInstalled, domain.PluginLifecycleStateEnabled, domain.PluginLifecycleStateDisabled, domain.PluginLifecycleStateUpgrading, domain.PluginLifecycleStateRollingBack, domain.PluginLifecycleStateRetired, domain.PluginLifecycleStateFailed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validPluginLifecycleOperation(operation domain.PluginLifecycleOperation) bool {
|
||||
switch operation {
|
||||
case domain.PluginLifecycleOperationInstall, domain.PluginLifecycleOperationEnable, domain.PluginLifecycleOperationDisable, domain.PluginLifecycleOperationUpgrade, domain.PluginLifecycleOperationRollback, domain.PluginLifecycleOperationRetire, domain.PluginLifecycleOperationDependencyCheck:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validAIConfigDiffState(state domain.AIConfigDiffState) bool {
|
||||
switch state {
|
||||
case domain.AIConfigDiffStatePending, domain.AIConfigDiffStateApproved, domain.AIConfigDiffStateCancelled, domain.AIConfigDiffStateExpired:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func unsafeProductionText(value string) bool {
|
||||
lower := strings.ToLower(value)
|
||||
return containsUnsafeRuntimeSecret(value) ||
|
||||
looksLikeRawHostPath(value) ||
|
||||
strings.Contains(lower, "api key") ||
|
||||
strings.Contains(lower, "apikey") ||
|
||||
strings.Contains(lower, "provider base url") ||
|
||||
strings.Contains(lower, "direct run") ||
|
||||
strings.Contains(lower, "rcon password") ||
|
||||
strings.Contains(lower, "dsn=")
|
||||
}
|
||||
@@ -20,6 +20,7 @@ const (
|
||||
maxServerConfigContentSize = 64 * 1024
|
||||
maxJobExecutionContentSize = 64 * 1024
|
||||
maxLogicalFileKeyLength = 160
|
||||
maxProductionMessageLength = 320
|
||||
)
|
||||
|
||||
type ValidationError struct {
|
||||
@@ -146,11 +147,14 @@ func ValidateGamePlugin(plugin domain.GamePlugin) error {
|
||||
violations = append(violations, validatePluginPages(plugin.Pages)...)
|
||||
violations = append(violations, duplicateViolations("tags", plugin.Tags)...)
|
||||
violations = append(violations, validateAIPurposes(plugin.AIPurposes)...)
|
||||
violations = append(violations, validateProductionLifecycle("productionLifecycle", plugin.ProductionLifecycle, true)...)
|
||||
violations = append(violations, validateRemoteAccess("remoteAccess", plugin.RemoteAccess, plugin.RequiredRunCapabilities)...)
|
||||
if err := ValidateGamePluginRuntimeProfiles(plugin.RuntimeProfiles); err != nil {
|
||||
violations = append(violations, err.Error())
|
||||
}
|
||||
violations = append(violations, validateRuntimeProfileCapabilityDeclarations(plugin.RuntimeProfiles, plugin.RequiredRunCapabilities)...)
|
||||
violations = append(violations, validateRuntimeLogEventPermissionDeclarations("runtimeProfiles.logEvents", plugin.RuntimeProfiles, plugin.DeclaredPermissions)...)
|
||||
violations = append(violations, validateGameClientBridgeManifest("gameClientBridge", plugin.GameClientBridge, plugin.DeclaredPermissions, plugin.Pages, plugin.RuntimeProfiles)...)
|
||||
violations = append(violations, validateSafePluginStrings("gamePlugin", pluginSafeStrings(plugin))...)
|
||||
return finish(violations)
|
||||
}
|
||||
@@ -202,15 +206,262 @@ func ValidateGamePluginManifestRegistration(registration domain.GamePluginManife
|
||||
violations = append(violations, validatePluginPages(manifest.Pages)...)
|
||||
violations = append(violations, duplicateViolations("manifest.tags", manifest.Tags)...)
|
||||
violations = append(violations, validateAIPurposes(manifest.AI.Purposes)...)
|
||||
violations = append(violations, validateProductionLifecycle("manifest.productionLifecycle", manifest.ProductionLifecycle, true)...)
|
||||
if containsString(manifest.Permissions, "ai.invoke") || len(manifest.AI.Purposes) > 0 {
|
||||
if manifest.AI.Mediation != "platform" {
|
||||
violations = append(violations, "manifest.ai.mediation must be platform")
|
||||
}
|
||||
if manifest.AI.ConfigWritePolicy != "review-required" {
|
||||
violations = append(violations, "manifest.ai.configWritePolicy must be review-required")
|
||||
}
|
||||
}
|
||||
violations = append(violations, validateRemoteAccess("manifest.remoteAccess", manifest.RemoteAccess, manifest.Capabilities)...)
|
||||
if err := ValidateGamePluginRuntimeProfiles(manifest.RuntimeProfiles); err != nil {
|
||||
violations = append(violations, err.Error())
|
||||
}
|
||||
violations = append(violations, validateRuntimeProfileCapabilityDeclarations(manifest.RuntimeProfiles, manifest.Capabilities)...)
|
||||
violations = append(violations, validateRuntimeLogEventPermissionDeclarations("manifest.runtimeProfiles.logEvents", manifest.RuntimeProfiles, manifest.Permissions)...)
|
||||
violations = append(violations, validateGameClientBridgeManifest("manifest.gameClientBridge", manifest.GameClientBridge, manifest.Permissions, manifest.Pages, manifest.RuntimeProfiles)...)
|
||||
violations = append(violations, validateSafePluginStrings("manifest", manifestSafeStrings(registration))...)
|
||||
return finish(violations)
|
||||
}
|
||||
|
||||
func validateGameClientBridgeManifest(field string, bridge domain.GameClientBridgeManifest, permissions []string, pages []domain.GamePluginPage, runtimeProfiles domain.GamePluginRuntimeProfiles) []string {
|
||||
companionPresent := bridge.Companion != (domain.GameClientBridgeCompanionDeclaration{})
|
||||
if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.Pages) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent {
|
||||
return nil
|
||||
}
|
||||
var violations []string
|
||||
if bridge.Retention.KeepForSeconds <= 0 || bridge.Retention.KeepForSeconds > 365*24*60*60 {
|
||||
violations = append(violations, field+".commandRetentionSeconds is invalid")
|
||||
}
|
||||
if bridge.Retention.MaxRecords <= 0 || bridge.Retention.MaxRecords > 100000 {
|
||||
violations = append(violations, field+".maxCommands is invalid")
|
||||
}
|
||||
if companionPresent {
|
||||
prefix := field + ".companion"
|
||||
companion := bridge.Companion
|
||||
if !clientManagerIdentifierPattern.MatchString(companion.ProfileKey) || !clientManagerIdentifierPattern.MatchString(companion.ConfigTemplateKey) {
|
||||
violations = append(violations, prefix+" profile or config template key is invalid")
|
||||
}
|
||||
if !safeRelativeJSONRef(companion.ConfigSchemaRef) {
|
||||
violations = append(violations, prefix+".configSchemaRef must be a safe relative JSON reference")
|
||||
}
|
||||
if companion.ConfigFormat != "yaml" || companion.PlatformBaseURLSource != "run-control" || companion.RegistrationProof != "hmac-sha256" || companion.ProofMaterialSource != "component-package" || companion.SessionMode != "component-session" || companion.TLSPolicy != "verify-system-roots" {
|
||||
violations = append(violations, prefix+" bootstrap security policy is invalid")
|
||||
}
|
||||
if !validCompanionProofEnvironment(companion.ProofMaterialEnv) {
|
||||
violations = append(violations, prefix+".proofMaterialEnv is invalid")
|
||||
}
|
||||
if companion.HeartbeatIntervalSeconds < 5 || companion.HeartbeatIntervalSeconds > 300 || companion.CommandPollIntervalSeconds < 1 || companion.CommandPollIntervalSeconds > 60 || companion.RequestTimeoutSeconds < 1 || companion.RequestTimeoutSeconds > 60 {
|
||||
violations = append(violations, prefix+" timing policy is invalid")
|
||||
}
|
||||
managerFound := false
|
||||
for _, manager := range runtimeProfiles.ClientManagers {
|
||||
if manager.Key != companion.ProfileKey {
|
||||
continue
|
||||
}
|
||||
managerFound = true
|
||||
if manager.Health.IntervalSeconds != companion.HeartbeatIntervalSeconds {
|
||||
violations = append(violations, prefix+".heartbeatIntervalSeconds must match the Client Manager health interval")
|
||||
}
|
||||
for _, capability := range []string{"component.register", "component.heartbeat", "component.health", "game-client.bridge"} {
|
||||
if !containsString(manager.Health.RequiredCapabilities, capability) {
|
||||
violations = append(violations, prefix+" requires Client Manager capability "+capability)
|
||||
}
|
||||
}
|
||||
templateFound := false
|
||||
for _, template := range manager.ConfigTemplates {
|
||||
if template.Key == companion.ConfigTemplateKey {
|
||||
templateFound = true
|
||||
if template.OutputRef != "config.yaml" {
|
||||
violations = append(violations, prefix+" config template must materialize config.yaml")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !templateFound {
|
||||
violations = append(violations, prefix+".configTemplateKey must reference the Client Manager profile")
|
||||
}
|
||||
}
|
||||
if !managerFound {
|
||||
violations = append(violations, prefix+".profileKey must reference a declared Client Manager profile")
|
||||
}
|
||||
}
|
||||
commandTypes := map[string]struct{}{}
|
||||
for index, command := range bridge.Commands {
|
||||
prefix := fmt.Sprintf("%s.commands[%d]", field, index)
|
||||
if !clientManagerIdentifierPattern.MatchString(command.Type) || unsafeGameClientBridgeCommandType(command.Type) {
|
||||
violations = append(violations, prefix+".type is invalid or unsafe")
|
||||
}
|
||||
if _, exists := commandTypes[command.Type]; exists {
|
||||
violations = append(violations, prefix+".type is duplicated")
|
||||
}
|
||||
commandTypes[command.Type] = struct{}{}
|
||||
if strings.TrimSpace(command.Title) == "" || len([]rune(command.Title)) > 80 {
|
||||
violations = append(violations, prefix+".title is invalid")
|
||||
}
|
||||
if !containsString(permissions, command.Permission) {
|
||||
violations = append(violations, prefix+".permission must be declared by the plugin")
|
||||
}
|
||||
if command.ApprovalLevel != domain.GameClientBridgeApprovalLevelNone && command.ApprovalLevel != domain.GameClientBridgeApprovalLevelOperator && command.ApprovalLevel != domain.GameClientBridgeApprovalLevelPlatformAdmin {
|
||||
violations = append(violations, prefix+".approvalLevel is invalid")
|
||||
}
|
||||
if !safeRelativeJSONRef(command.PayloadSchemaRef) || command.ResultSchemaRef != "" && !safeRelativeJSONRef(command.ResultSchemaRef) {
|
||||
violations = append(violations, prefix+" schema references must be safe relative JSON references")
|
||||
}
|
||||
if command.TimeoutSeconds <= 0 || command.TimeoutSeconds > 3600 {
|
||||
violations = append(violations, prefix+".timeoutSeconds is invalid")
|
||||
}
|
||||
if command.MaxPayloadBytes <= 0 || command.MaxPayloadBytes > maxGameClientBridgePayloadSize {
|
||||
violations = append(violations, prefix+".maxPayloadBytes is invalid")
|
||||
}
|
||||
}
|
||||
snapshotTypes := map[string]struct{}{}
|
||||
for index, snapshot := range bridge.Snapshots {
|
||||
prefix := fmt.Sprintf("%s.snapshots[%d]", field, index)
|
||||
key := snapshot.Type + "\x00" + snapshot.SchemaVersion
|
||||
if !clientManagerIdentifierPattern.MatchString(snapshot.Type) || !clientManagerIdentifierPattern.MatchString(snapshot.SchemaVersion) {
|
||||
violations = append(violations, prefix+" type or schemaVersion is invalid")
|
||||
}
|
||||
if _, exists := snapshotTypes[key]; exists {
|
||||
violations = append(violations, prefix+" type and schemaVersion are duplicated")
|
||||
}
|
||||
snapshotTypes[key] = struct{}{}
|
||||
if !safeRelativeJSONRef(snapshot.SchemaRef) {
|
||||
violations = append(violations, prefix+".schemaRef must be a safe relative JSON reference")
|
||||
}
|
||||
if snapshot.Retention.KeepForSeconds <= 0 || snapshot.Retention.KeepForSeconds > 31*24*60*60 || snapshot.Retention.MaxRecords <= 0 || snapshot.Retention.MaxRecords > 10000 {
|
||||
violations = append(violations, prefix+" retention is invalid")
|
||||
}
|
||||
}
|
||||
queryTemplates := map[string]domain.GameClientBridgeQueryTemplateDeclaration{}
|
||||
transports := map[string]domain.RuntimeTransportProfile{}
|
||||
for _, transport := range runtimeProfiles.TransportProfiles {
|
||||
transports[transport.Key] = transport
|
||||
}
|
||||
for index, template := range bridge.QueryTemplates {
|
||||
prefix := fmt.Sprintf("%s.queryTemplates[%d]", field, index)
|
||||
if !clientManagerIdentifierPattern.MatchString(template.Key) {
|
||||
violations = append(violations, prefix+".key is invalid")
|
||||
}
|
||||
if _, exists := queryTemplates[template.Key]; exists {
|
||||
violations = append(violations, prefix+".key is duplicated")
|
||||
}
|
||||
queryTemplates[template.Key] = template
|
||||
if strings.TrimSpace(template.Title) == "" || len([]rune(template.Title)) > 80 {
|
||||
violations = append(violations, prefix+".title is invalid")
|
||||
}
|
||||
if !containsString(permissions, template.Permission) {
|
||||
violations = append(violations, prefix+".permission must be declared by the plugin")
|
||||
}
|
||||
if template.Engine != "sqlite" {
|
||||
violations = append(violations, prefix+".engine must be sqlite")
|
||||
}
|
||||
if !safeRelativeJSONRef(template.ParameterSchemaRef) || !safeRelativeJSONRef(template.ResultSchemaRef) {
|
||||
violations = append(violations, prefix+" schema references must be safe relative JSON references")
|
||||
}
|
||||
if template.MaxRows < 1 || template.MaxRows > 500 {
|
||||
violations = append(violations, prefix+".maxRows is invalid")
|
||||
}
|
||||
if template.TimeoutSeconds < 1 || template.TimeoutSeconds > 60 {
|
||||
violations = append(violations, prefix+".timeoutSeconds is invalid")
|
||||
}
|
||||
transport, exists := transports[template.TransportKey]
|
||||
if !exists {
|
||||
violations = append(violations, prefix+".transportKey must reference a declared runtime transport profile")
|
||||
continue
|
||||
}
|
||||
if transport.TargetKey != template.TargetKey || strings.TrimSpace(template.TargetKey) == "" {
|
||||
violations = append(violations, prefix+".targetKey must match the declared runtime transport profile")
|
||||
}
|
||||
if transport.Kind != "sqlite" || !containsString(transport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) {
|
||||
violations = append(violations, prefix+" transport must be sqlite with remote.run.db.sqlite.query capability")
|
||||
}
|
||||
}
|
||||
pageDeclarations := map[string]domain.GamePluginPage{}
|
||||
for _, page := range pages {
|
||||
pageDeclarations[page.Key] = page
|
||||
}
|
||||
for index, page := range bridge.Pages {
|
||||
prefix := fmt.Sprintf("%s.pages[%d]", field, index)
|
||||
pageDeclaration, pageExists := pageDeclarations[page.PageKey]
|
||||
if !pageExists {
|
||||
violations = append(violations, prefix+".pageKey must reference a declared plugin page")
|
||||
}
|
||||
for _, commandType := range page.CommandTypes {
|
||||
if _, exists := commandTypes[commandType]; !exists {
|
||||
violations = append(violations, prefix+" references undeclared command "+commandType)
|
||||
}
|
||||
}
|
||||
for _, snapshotType := range page.SnapshotTypes {
|
||||
found := false
|
||||
for key := range snapshotTypes {
|
||||
if strings.HasPrefix(key, snapshotType+"\x00") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
violations = append(violations, prefix+" references undeclared snapshot "+snapshotType)
|
||||
}
|
||||
}
|
||||
for _, templateKey := range page.QueryTemplateKeys {
|
||||
template, exists := queryTemplates[templateKey]
|
||||
if !exists {
|
||||
violations = append(violations, prefix+" references undeclared query template "+templateKey)
|
||||
continue
|
||||
}
|
||||
if !containsString(pageDeclaration.Permissions, template.Permission) {
|
||||
violations = append(violations, prefix+" must declare query template permission "+template.Permission)
|
||||
}
|
||||
if !containsString(pageDeclaration.BridgeActions, string(domain.PluginBridgeActionRemoteAccessRequest)) {
|
||||
violations = append(violations, prefix+" must declare remote.access.request for query templates")
|
||||
}
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func validCompanionProofEnvironment(value string) bool {
|
||||
if len(value) < 3 || len(value) > 64 || value[0] < 'A' || value[0] > 'Z' {
|
||||
return false
|
||||
}
|
||||
for _, character := range value[1:] {
|
||||
if character != '_' && (character < 'A' || character > 'Z') && (character < '0' || character > '9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
reserved := map[string]struct{}{
|
||||
"COMSPEC": {}, "DYLD_INSERT_LIBRARIES": {}, "DYLD_LIBRARY_PATH": {}, "HOME": {}, "LD_LIBRARY_PATH": {}, "LD_PRELOAD": {},
|
||||
"PATH": {}, "PATHEXT": {}, "SHELL": {}, "SYSTEMROOT": {}, "TEMP": {}, "TMP": {}, "USERPROFILE": {}, "WINDIR": {},
|
||||
}
|
||||
if _, exists := reserved[value]; exists {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func unsafeGameClientBridgeCommandType(value string) bool {
|
||||
tokens := gameClientBridgePayloadKeyTokens(value)
|
||||
tokenSet := make(map[string]struct{}, len(tokens))
|
||||
for _, token := range tokens {
|
||||
tokenSet[token] = struct{}{}
|
||||
}
|
||||
has := func(values ...string) bool {
|
||||
for _, candidate := range values {
|
||||
if _, ok := tokenSet[candidate]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if has("sql") || has("database", "db") && has("execute", "exec", "eval", "run", "query", "statement") || has("query") && has("execute", "exec", "eval", "raw", "statement") {
|
||||
return true
|
||||
}
|
||||
return has("shell", "powershell", "script", "terminal", "execute", "exec", "eval") || has("command", "cmd", "process", "system", "os", "executor") && has("run")
|
||||
}
|
||||
|
||||
func ValidatePluginBridgeAuthorizeRequest(request domain.PluginBridgeAuthorizeRequest) error {
|
||||
var violations []string
|
||||
violations = appendRequired(violations, "pluginId", request.PluginID)
|
||||
@@ -701,12 +952,7 @@ func ValidateRunEndpoint(endpoint domain.RunEndpoint) error {
|
||||
if !validRunEndpointStatus(endpoint.Status) {
|
||||
violations = append(violations, "status is invalid")
|
||||
}
|
||||
if endpoint.Capacity.MaxJobs < 0 || endpoint.Capacity.RunningJobs < 0 || endpoint.Capacity.QueuedJobs < 0 {
|
||||
violations = append(violations, "capacity counts must not be negative")
|
||||
}
|
||||
if endpoint.Capacity.MaxJobs > 0 && endpoint.Capacity.RunningJobs > endpoint.Capacity.MaxJobs {
|
||||
violations = append(violations, "runningJobs must not exceed maxJobs")
|
||||
}
|
||||
violations = appendCapacityViolations(violations, endpoint.Capacity)
|
||||
for i, capability := range endpoint.Capabilities {
|
||||
if strings.TrimSpace(capability) == "" {
|
||||
violations = append(violations, fmt.Sprintf("capabilities[%d] is required", i))
|
||||
@@ -766,6 +1012,7 @@ func ValidateJob(job domain.Job) error {
|
||||
if job.ExecutionInput.ExpectedChecksum != "" && !validSHA256Checksum(job.ExecutionInput.ExpectedChecksum) {
|
||||
violations = append(violations, "executionInput.expectedChecksum must be sha256:<hex>")
|
||||
}
|
||||
violations = append(violations, validateRemoteAdapterInputs("executionInput.inputs", job.ExecutionInput.Inputs)...)
|
||||
if job.ExecutionResult.Checksum != "" && !validSHA256Checksum(job.ExecutionResult.Checksum) {
|
||||
violations = append(violations, "executionResult.checksum must be sha256:<hex>")
|
||||
}
|
||||
@@ -1065,6 +1312,9 @@ func pluginSafeStrings(plugin domain.GamePlugin) []fieldString {
|
||||
values = appendStringSliceFields(values, "declaredPermissions", plugin.DeclaredPermissions)
|
||||
values = appendStringSliceFields(values, "tags", plugin.Tags)
|
||||
values = appendStringSliceFields(values, "aiPurposes", plugin.AIPurposes)
|
||||
values = appendStringSliceFields(values, "productionLifecycle.operations", plugin.ProductionLifecycle.Operations)
|
||||
values = appendStringSliceFields(values, "productionLifecycle.approvalRequired", plugin.ProductionLifecycle.ApprovalRequired)
|
||||
values = append(values, fieldString{field: "productionLifecycle.dependencyPolicy", value: plugin.ProductionLifecycle.DependencyPolicy})
|
||||
values = appendStringSliceFields(values, "bridgeActions", plugin.BridgeActions)
|
||||
values = appendStringSliceFields(values, "remoteAccess.methods", plugin.RemoteAccess.Methods)
|
||||
values = appendStringSliceFields(values, "remoteAccess.runCapabilities", plugin.RemoteAccess.RunCapabilities)
|
||||
@@ -1105,6 +1355,9 @@ func manifestSafeStrings(registration domain.GamePluginManifestRegistration) []f
|
||||
values = appendStringSliceFields(values, "capabilities", manifest.Capabilities)
|
||||
values = appendStringSliceFields(values, "permissions", manifest.Permissions)
|
||||
values = appendStringSliceFields(values, "ai.purposes", manifest.AI.Purposes)
|
||||
values = append(values, fieldString{field: "ai.mediation", value: manifest.AI.Mediation}, fieldString{field: "ai.configWritePolicy", value: manifest.AI.ConfigWritePolicy}, fieldString{field: "productionLifecycle.dependencyPolicy", value: manifest.ProductionLifecycle.DependencyPolicy})
|
||||
values = appendStringSliceFields(values, "productionLifecycle.operations", manifest.ProductionLifecycle.Operations)
|
||||
values = appendStringSliceFields(values, "productionLifecycle.approvalRequired", manifest.ProductionLifecycle.ApprovalRequired)
|
||||
values = appendStringSliceFields(values, "remoteAccess.methods", manifest.RemoteAccess.Methods)
|
||||
values = appendStringSliceFields(values, "remoteAccess.runCapabilities", manifest.RemoteAccess.RunCapabilities)
|
||||
values = appendStringSliceFields(values, "remoteAccess.databaseEngines", manifest.RemoteAccess.DatabaseEngines)
|
||||
@@ -1407,7 +1660,7 @@ func validScopedInputRef(ref string) bool {
|
||||
|
||||
func validPluginPermission(permission string) bool {
|
||||
switch permission {
|
||||
case "server.create", "server.read", "server.lifecycle", "server.files.read", "server.files.write", "server.logs.read", "server.artifacts.read", "server.artifacts.write", "server.remote.access", "server.run.distribution", "server.dependencies.manage", "server.client-manager.manage", "ai.invoke":
|
||||
case "server.create", "server.read", "server.lifecycle", "server.files.read", "server.files.write", "server.logs.read", "server.artifacts.read", "server.artifacts.write", "server.remote.access", "server.run.distribution", "server.dependencies.manage", "server.client-manager.manage", "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "ai.invoke":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -1426,6 +1679,7 @@ func validPluginBridgeAction(action domain.PluginBridgeAction) bool {
|
||||
domain.PluginBridgeActionDependenciesRequest,
|
||||
domain.PluginBridgeActionLogsBackfillRequest,
|
||||
domain.PluginBridgeActionClientManager,
|
||||
domain.PluginBridgeActionPluginLifecycle,
|
||||
domain.PluginBridgeActionAIInvoke:
|
||||
return true
|
||||
default:
|
||||
@@ -1455,6 +1709,8 @@ func requiredBridgePermissions(action domain.PluginBridgeAction) []string {
|
||||
return []string{"server.logs.read"}
|
||||
case domain.PluginBridgeActionClientManager:
|
||||
return []string{"server.client-manager.manage"}
|
||||
case domain.PluginBridgeActionPluginLifecycle:
|
||||
return []string{"server.lifecycle"}
|
||||
case domain.PluginBridgeActionAIInvoke:
|
||||
return []string{"ai.invoke"}
|
||||
default:
|
||||
@@ -1462,6 +1718,35 @@ func requiredBridgePermissions(action domain.PluginBridgeAction) []string {
|
||||
}
|
||||
}
|
||||
|
||||
func validateProductionLifecycle(field string, lifecycle domain.GamePluginProductionLifecycle, required bool) []string {
|
||||
var violations []string
|
||||
if required && len(lifecycle.Operations) == 0 {
|
||||
violations = append(violations, field+".operations must not be empty")
|
||||
}
|
||||
for i, operation := range lifecycle.Operations {
|
||||
switch domain.PluginLifecycleOperation(operation) {
|
||||
case domain.PluginLifecycleOperationInstall, domain.PluginLifecycleOperationEnable, domain.PluginLifecycleOperationDisable, domain.PluginLifecycleOperationUpgrade, domain.PluginLifecycleOperationRollback, domain.PluginLifecycleOperationRetire, domain.PluginLifecycleOperationDependencyCheck:
|
||||
default:
|
||||
violations = append(violations, fmt.Sprintf("%s.operations[%d] is invalid", field, i))
|
||||
}
|
||||
}
|
||||
violations = append(violations, duplicateViolations(field+".operations", lifecycle.Operations)...)
|
||||
if lifecycle.DependencyPolicy != "required" && lifecycle.DependencyPolicy != "optional" {
|
||||
violations = append(violations, field+".dependencyPolicy must be required or optional")
|
||||
}
|
||||
for i, operation := range lifecycle.ApprovalRequired {
|
||||
if operation != string(domain.PluginLifecycleOperationDisable) && operation != string(domain.PluginLifecycleOperationRollback) && operation != string(domain.PluginLifecycleOperationRetire) {
|
||||
violations = append(violations, fmt.Sprintf("%s.approvalRequired[%d] is invalid", field, i))
|
||||
}
|
||||
}
|
||||
for _, operation := range []string{string(domain.PluginLifecycleOperationDisable), string(domain.PluginLifecycleOperationRollback), string(domain.PluginLifecycleOperationRetire)} {
|
||||
if containsString(lifecycle.Operations, operation) && !containsString(lifecycle.ApprovalRequired, operation) {
|
||||
violations = append(violations, field+".approvalRequired must include "+operation)
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func effectivePagePermissions(plugin domain.GamePlugin, routeKey string) []string {
|
||||
declared := plugin.DeclaredPermissions
|
||||
page, found := findPluginPage(plugin.Pages, routeKey)
|
||||
|
||||
@@ -72,6 +72,106 @@ func TestValidateGamePluginManifestRegistrationValidatesRuntimeProfiles(t *testi
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateGamePluginManifestRegistrationValidatesGameClientBridgeCatalog(t *testing.T) {
|
||||
registration := validGamePluginManifestRegistration()
|
||||
registration.Manifest.Permissions = append(registration.Manifest.Permissions, "server.game-client.read", "server.game-client.command", "server.remote.access")
|
||||
registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery)
|
||||
registration.Manifest.Pages[0].Permissions = append(registration.Manifest.Pages[0].Permissions, "server.game-client.read", "server.remote.access")
|
||||
registration.Manifest.Pages[0].BridgeActions = append(registration.Manifest.Pages[0].BridgeActions, string(domain.PluginBridgeActionRemoteAccessRequest))
|
||||
registration.Manifest.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "sqlite-db", Kind: "sqlite", TargetKey: "db/sqlite", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}}}
|
||||
registration.Manifest.GameClientBridge = domain.GameClientBridgeManifest{
|
||||
Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "announcement.send", Title: "Send announcement", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, PayloadSchemaRef: "schemas/bridge/announcement.schema.json", ResultSchemaRef: "schemas/bridge/announcement-result.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 4096}},
|
||||
Snapshots: []domain.GameClientBridgeSnapshotDeclaration{{Type: "players", SchemaVersion: "1", SchemaRef: "schemas/bridge/players.schema.json", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}},
|
||||
QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", Title: "Player lookup", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "sqlite-db", TargetKey: "db/sqlite", ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", MaxRows: 50, TimeoutSeconds: 10}},
|
||||
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000},
|
||||
Pages: []domain.GameClientBridgePageContract{{PageKey: "logs", CommandTypes: []string{"announcement.send"}, SnapshotTypes: []string{"players"}, QueryTemplateKeys: []string{"player.lookup"}}},
|
||||
}
|
||||
if err := ValidateGamePluginManifestRegistration(registration); err != nil {
|
||||
t.Fatalf("expected bridge catalog to validate, got %v", err)
|
||||
}
|
||||
|
||||
for _, commandType := range []string{"sql.execute", "sqlExecute", "database.execute", "database.query", "shell.execute", "powershell.execute", "script.run", "terminal.execute", "command.run"} {
|
||||
t.Run("unsafe command type "+commandType, func(t *testing.T) {
|
||||
unsafeType := registration
|
||||
unsafeType.Manifest.GameClientBridge = domain.CopyGameClientBridgeManifest(registration.Manifest.GameClientBridge)
|
||||
unsafeType.Manifest.GameClientBridge.Commands[0].Type = commandType
|
||||
err := ValidateGamePluginManifestRegistration(unsafeType)
|
||||
if err == nil || !strings.Contains(err.Error(), "type is invalid or unsafe") {
|
||||
t.Fatalf("expected %q to be rejected, got %v", commandType, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
unsafe := registration
|
||||
unsafe.Manifest.GameClientBridge = domain.CopyGameClientBridgeManifest(registration.Manifest.GameClientBridge)
|
||||
unsafe.Manifest.GameClientBridge.Commands[0].Type = "shell.execute"
|
||||
unsafe.Manifest.GameClientBridge.Commands[0].ApprovalLevel = ""
|
||||
unsafe.Manifest.GameClientBridge.Commands[0].PayloadSchemaRef = "/etc/command.json"
|
||||
unsafe.Manifest.GameClientBridge.Pages[0].CommandTypes = []string{"undeclared.command"}
|
||||
err := ValidateGamePluginManifestRegistration(unsafe)
|
||||
if err == nil {
|
||||
t.Fatal("expected unsafe bridge catalog rejection")
|
||||
}
|
||||
for _, expected := range []string{"type is invalid or unsafe", "approvalLevel is invalid", "schema references", "undeclared command"} {
|
||||
if !strings.Contains(err.Error(), expected) {
|
||||
t.Fatalf("expected %q in validation error: %v", expected, err)
|
||||
}
|
||||
}
|
||||
|
||||
queryTemplateTests := []struct {
|
||||
name string
|
||||
expected string
|
||||
mutate func(*domain.GamePluginManifestRegistration)
|
||||
}{
|
||||
{name: "duplicate key", expected: "key is duplicated", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.GameClientBridge.QueryTemplates = append(value.Manifest.GameClientBridge.QueryTemplates, value.Manifest.GameClientBridge.QueryTemplates[0])
|
||||
}},
|
||||
{name: "unsupported engine", expected: "engine must be sqlite", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.GameClientBridge.QueryTemplates[0].Engine = "mysql"
|
||||
}},
|
||||
{name: "undeclared permission", expected: "permission must be declared", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.GameClientBridge.QueryTemplates[0].Permission = "server.database.admin"
|
||||
}},
|
||||
{name: "unsafe schema", expected: "schema references", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.GameClientBridge.QueryTemplates[0].ParameterSchemaRef = "/etc/query.json"
|
||||
}},
|
||||
{name: "row bound", expected: "maxRows is invalid", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.GameClientBridge.QueryTemplates[0].MaxRows = 501
|
||||
}},
|
||||
{name: "timeout bound", expected: "timeoutSeconds is invalid", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.GameClientBridge.QueryTemplates[0].TimeoutSeconds = 61
|
||||
}},
|
||||
{name: "unknown transport", expected: "transportKey must reference", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.GameClientBridge.QueryTemplates[0].TransportKey = "missing"
|
||||
}},
|
||||
{name: "target mismatch", expected: "targetKey must match", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.GameClientBridge.QueryTemplates[0].TargetKey = "db/other"
|
||||
}},
|
||||
{name: "missing sqlite capability", expected: "transport must be sqlite", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.RuntimeProfiles.TransportProfiles[0].Capabilities = []string{"files.read"}
|
||||
}},
|
||||
{name: "undeclared page template", expected: "undeclared query template", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.GameClientBridge.Pages[0].QueryTemplateKeys = []string{"missing.lookup"}
|
||||
}},
|
||||
{name: "page missing template permission", expected: "must declare query template permission", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.Pages[0].Permissions = []string{"server.logs.read", "server.remote.access"}
|
||||
}},
|
||||
{name: "page missing remote action", expected: "must declare remote.access.request", mutate: func(value *domain.GamePluginManifestRegistration) {
|
||||
value.Manifest.Pages[0].BridgeActions = nil
|
||||
}},
|
||||
}
|
||||
for _, test := range queryTemplateTests {
|
||||
t.Run("query template "+test.name, func(t *testing.T) {
|
||||
invalid := domain.CopyGamePluginManifestRegistration(registration)
|
||||
test.mutate(&invalid)
|
||||
err := ValidateGamePluginManifestRegistration(invalid)
|
||||
if err == nil || !strings.Contains(err.Error(), test.expected) {
|
||||
t.Fatalf("expected %q rejection, got %v", test.expected, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGamePluginManifestRegistrationRejectsUnsafeCapabilitiesAndPermissions(t *testing.T) {
|
||||
registration := validGamePluginManifestRegistration()
|
||||
registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, "run.socket")
|
||||
@@ -235,7 +335,8 @@ func validGamePluginManifestRegistration() domain.GamePluginManifestRegistration
|
||||
Pages: []domain.GamePluginPage{
|
||||
{Key: "logs", Title: "Logs", Path: "/logs", Permissions: []string{"server.logs.read"}},
|
||||
},
|
||||
AI: domain.GamePluginManifestAI{Purposes: []string{"logs.diagnose"}},
|
||||
AI: domain.GamePluginManifestAI{Purposes: []string{"logs.diagnose"}, Mediation: "platform", ConfigWritePolicy: "review-required"},
|
||||
ProductionLifecycle: domain.GamePluginProductionLifecycle{Operations: []string{"install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"}, DependencyPolicy: "optional", ApprovalRequired: []string{"disable", "rollback", "retire"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,3 +8,6 @@
|
||||
- `platform/validator/resources.go` validates required IDs, enum values, AI key-reference shape, bounded progress/audit summaries, artifact metadata, log stream cursors, and run capability compatibility.
|
||||
- `platform/service.Core` must call validators before repository writes and must reject server creation when the plugin is not installed, the run endpoint is disabled/offline, or required run capabilities are missing.
|
||||
- Job creation must require an idempotency key and return the existing job for duplicate `(runEndpointId, idempotencyKey)` pairs.
|
||||
# Client Manager lifecycle validation
|
||||
|
||||
Lifecycle validation rejects undeclared operations, stale attempt/deployment/key generations, cross-owner/server/profile/target/revision artifacts, unavailable endpoints, raw secrets, endpoint/socket values, traversal or absolute executable references, shell metacharacters, and unbounded timeouts. Registration additionally requires a current component-key HMAC, fresh nonce/timestamp, matching artifact and capabilities, and a monotonic heartbeat sequence. Safe DTOs are redacted before they cross the Platform boundary.
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
func validRuntimeLogEventProfiles() domain.GamePluginRuntimeProfiles {
|
||||
return domain.GamePluginRuntimeProfiles{
|
||||
LogSources: []domain.RuntimeLogSource{{Key: "chat-log", Kind: "file.tail", StreamKey: "chat", CursorKind: "offset", RetentionDays: 30}},
|
||||
LogEvents: []domain.RuntimeLogEvent{{
|
||||
Key: "chat-message", Title: "Chat message", SourceKey: "chat-log", EventType: "chat.message",
|
||||
Permission: "server.logs.read", SchemaRef: "schemas/log-events/chat-message.schema.json", RetentionDays: 30, Severity: "info",
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGamePluginRuntimeProfilesValidatesLogEvents(t *testing.T) {
|
||||
if err := ValidateGamePluginRuntimeProfiles(validRuntimeLogEventProfiles()); err != nil {
|
||||
t.Fatalf("expected valid runtime log event declaration, got %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expected string
|
||||
mutate func(*domain.GamePluginRuntimeProfiles)
|
||||
}{
|
||||
{name: "undeclared source", expected: "sourceKey must reference", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].SourceKey = "missing" }},
|
||||
{name: "invalid event type", expected: "eventType is invalid", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].EventType = "chat message" }},
|
||||
{name: "invalid permission", expected: "permission is not allowed", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].Permission = "server.admin" }},
|
||||
{name: "unsafe schema", expected: "schemaRef must be a bounded safe relative JSON reference", mutate: func(profiles *domain.GamePluginRuntimeProfiles) {
|
||||
profiles.LogEvents[0].SchemaRef = "schemas/log events/chat.json"
|
||||
}},
|
||||
{name: "retention bound", expected: "retentionDays is invalid", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].RetentionDays = 366 }},
|
||||
{name: "retention exceeds source", expected: "retentionDays must not exceed the source retention", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].RetentionDays = 31 }},
|
||||
{name: "invalid severity", expected: "severity is invalid", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].Severity = "emergency" }},
|
||||
{name: "plugin error severity", expected: "severity is invalid", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].Severity = "error" }},
|
||||
{name: "duplicate key", expected: "key is duplicated", mutate: func(profiles *domain.GamePluginRuntimeProfiles) {
|
||||
profiles.LogEvents = append(profiles.LogEvents, profiles.LogEvents[0])
|
||||
}},
|
||||
{name: "duplicate event type", expected: "eventType is duplicated", mutate: func(profiles *domain.GamePluginRuntimeProfiles) {
|
||||
duplicate := profiles.LogEvents[0]
|
||||
duplicate.Key = "chat-message-copy"
|
||||
profiles.LogEvents = append(profiles.LogEvents, duplicate)
|
||||
}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
profiles := domain.CopyGamePluginRuntimeProfiles(validRuntimeLogEventProfiles())
|
||||
test.mutate(&profiles)
|
||||
err := ValidateGamePluginRuntimeProfiles(profiles)
|
||||
if err == nil || !strings.Contains(err.Error(), test.expected) {
|
||||
t.Fatalf("expected %q validation error, got %v", test.expected, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGamePluginRuntimeProfilesAcceptsDeclaredLogEventSeverities(t *testing.T) {
|
||||
for _, severity := range []domain.RuntimeLogEventSeverity{
|
||||
domain.RuntimeLogEventSeverityInfo,
|
||||
domain.RuntimeLogEventSeverityNotice,
|
||||
domain.RuntimeLogEventSeverityWarning,
|
||||
domain.RuntimeLogEventSeverityCritical,
|
||||
} {
|
||||
t.Run(string(severity), func(t *testing.T) {
|
||||
profiles := validRuntimeLogEventProfiles()
|
||||
profiles.LogEvents[0].Severity = severity
|
||||
if err := ValidateGamePluginRuntimeProfiles(profiles); err != nil {
|
||||
t.Fatalf("expected severity %q to validate, got %v", severity, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGamePluginRuntimeProfilesAllowsEventRetentionWhenSourceUsesDefault(t *testing.T) {
|
||||
profiles := validRuntimeLogEventProfiles()
|
||||
profiles.LogSources[0].RetentionDays = 0
|
||||
if err := ValidateGamePluginRuntimeProfiles(profiles); err != nil {
|
||||
t.Fatalf("expected source default retention to allow bounded event retention, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGamePluginRuntimeProfilesRejectsUnsafeLogEventSemantics(t *testing.T) {
|
||||
unsafeEventTypes := []string{
|
||||
"ops.shell.execute",
|
||||
"ops.execute",
|
||||
"audit.sql.query",
|
||||
"audit.raw-host-path",
|
||||
"run.socket.open",
|
||||
"auth.credential.exposed",
|
||||
"auth.api-key.exposed",
|
||||
}
|
||||
for _, eventType := range unsafeEventTypes {
|
||||
t.Run(eventType, func(t *testing.T) {
|
||||
profiles := validRuntimeLogEventProfiles()
|
||||
profiles.LogEvents[0].EventType = eventType
|
||||
err := ValidateGamePluginRuntimeProfiles(profiles)
|
||||
if err == nil || !strings.Contains(err.Error(), "eventType contains unsafe operation semantics") {
|
||||
t.Fatalf("expected unsafe event type %q to be rejected, got %v", eventType, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGamePluginManifestRegistrationRequiresDeclaredLogEventPermission(t *testing.T) {
|
||||
registration := validGamePluginManifestRegistration()
|
||||
registration.Manifest.RuntimeProfiles = validRuntimeLogEventProfiles()
|
||||
registration.Manifest.RuntimeProfiles.LogEvents[0].Permission = "server.game-client.read"
|
||||
|
||||
err := ValidateGamePluginManifestRegistration(registration)
|
||||
if err == nil || !strings.Contains(err.Error(), "manifest.runtimeProfiles.logEvents[0].permission must be declared by the plugin") {
|
||||
t.Fatalf("expected undeclared log event permission rejection, got %v", err)
|
||||
}
|
||||
|
||||
registration.Manifest.Permissions = append(registration.Manifest.Permissions, "server.game-client.read")
|
||||
if err := ValidateGamePluginManifestRegistration(registration); err != nil {
|
||||
t.Fatalf("expected declared log event permission to validate, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,11 @@ import (
|
||||
"browser.local/platform/domain"
|
||||
)
|
||||
|
||||
var (
|
||||
runtimeLogEventSchemaRefPattern = regexp.MustCompile(`^[A-Za-z0-9_./-]+\.json$`)
|
||||
runtimeLogEventTypePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,119}$`)
|
||||
)
|
||||
|
||||
func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles) error {
|
||||
profiles = domain.CopyGamePluginRuntimeProfiles(profiles)
|
||||
var violations []string
|
||||
@@ -21,6 +26,9 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
|
||||
dependencyKeys := map[string]struct{}{}
|
||||
installPlanKeys := map[string]struct{}{}
|
||||
logSourceKeys := map[string]struct{}{}
|
||||
logSourceRetentions := map[string]int{}
|
||||
logEventKeys := map[string]struct{}{}
|
||||
logEventTypes := map[string]struct{}{}
|
||||
|
||||
for i, probe := range profiles.Discovery {
|
||||
prefix := fmt.Sprintf("runtimeProfiles.discovery[%d]", i)
|
||||
@@ -138,6 +146,9 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
|
||||
prefix := fmt.Sprintf("runtimeProfiles.logSources[%d]", i)
|
||||
violations = append(violations, validateProfileKey(prefix+".key", source.Key)...)
|
||||
violations = append(violations, recordRuntimeProfileKey(logSourceKeys, prefix+".key", source.Key)...)
|
||||
if source.Key != "" {
|
||||
logSourceRetentions[source.Key] = source.RetentionDays
|
||||
}
|
||||
if !oneOf(source.Kind, "process.stdout", "process.stderr", "file.tail", "ftp.poll", "sql.query", "client-manager") {
|
||||
violations = append(violations, prefix+".kind is invalid")
|
||||
}
|
||||
@@ -152,6 +163,44 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
|
||||
violations = append(violations, prefix+".retentionDays is invalid")
|
||||
}
|
||||
}
|
||||
if len(profiles.LogEvents) > 128 {
|
||||
violations = append(violations, "runtimeProfiles.logEvents must not exceed 128")
|
||||
}
|
||||
for i, event := range profiles.LogEvents {
|
||||
prefix := fmt.Sprintf("runtimeProfiles.logEvents[%d]", i)
|
||||
violations = append(violations, validateProfileKey(prefix+".key", event.Key)...)
|
||||
violations = append(violations, recordRuntimeProfileKey(logEventKeys, prefix+".key", event.Key)...)
|
||||
if strings.TrimSpace(event.Title) == "" || len([]rune(event.Title)) > 80 {
|
||||
violations = append(violations, prefix+".title is invalid")
|
||||
}
|
||||
violations = append(violations, validateSafeRuntimeValue(prefix+".title", event.Title)...)
|
||||
violations = append(violations, validateProfileKey(prefix+".sourceKey", event.SourceKey)...)
|
||||
if _, exists := logSourceKeys[event.SourceKey]; !exists {
|
||||
violations = append(violations, prefix+".sourceKey must reference a declared runtime log source")
|
||||
}
|
||||
if !runtimeLogEventTypePattern.MatchString(event.EventType) {
|
||||
violations = append(violations, prefix+".eventType is invalid")
|
||||
}
|
||||
violations = append(violations, recordRuntimeProfileKey(logEventTypes, prefix+".eventType", event.EventType)...)
|
||||
if hasUnsafeRuntimeLogEventSemantics(event.EventType) {
|
||||
violations = append(violations, prefix+".eventType contains unsafe operation semantics")
|
||||
}
|
||||
if !validPluginPermission(event.Permission) {
|
||||
violations = append(violations, prefix+".permission is not allowed")
|
||||
}
|
||||
if len(event.SchemaRef) > 240 || !runtimeLogEventSchemaRefPattern.MatchString(event.SchemaRef) || !safeRelativeJSONRef(event.SchemaRef) {
|
||||
violations = append(violations, prefix+".schemaRef must be a bounded safe relative JSON reference")
|
||||
}
|
||||
if event.RetentionDays < 1 || event.RetentionDays > 365 {
|
||||
violations = append(violations, prefix+".retentionDays is invalid")
|
||||
}
|
||||
if sourceRetention, exists := logSourceRetentions[event.SourceKey]; exists && sourceRetention > 0 && event.RetentionDays > sourceRetention {
|
||||
violations = append(violations, prefix+".retentionDays must not exceed the source retention")
|
||||
}
|
||||
if !oneOf(string(event.Severity), string(domain.RuntimeLogEventSeverityInfo), string(domain.RuntimeLogEventSeverityNotice), string(domain.RuntimeLogEventSeverityWarning), string(domain.RuntimeLogEventSeverityCritical)) {
|
||||
violations = append(violations, prefix+".severity is invalid")
|
||||
}
|
||||
}
|
||||
for i, transport := range profiles.TransportProfiles {
|
||||
prefix := fmt.Sprintf("runtimeProfiles.transportProfiles[%d]", i)
|
||||
violations = append(violations, validateProfileKey(prefix+".key", transport.Key)...)
|
||||
@@ -346,6 +395,42 @@ func validateSafeRuntimeValue(field, value string) []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasUnsafeRuntimeLogEventSemantics(eventType string) bool {
|
||||
lowered := strings.ToLower(strings.TrimSpace(eventType))
|
||||
tokens := strings.FieldsFunc(lowered, func(char rune) bool {
|
||||
return char == '.' || char == '_' || char == '-' || char == '/'
|
||||
})
|
||||
unsafeTokens := map[string]struct{}{
|
||||
"apikey": {}, "credential": {}, "credentials": {}, "eval": {}, "exec": {},
|
||||
"execute": {}, "password": {}, "powershell": {}, "script": {}, "secret": {},
|
||||
"shell": {}, "socket": {}, "terminal": {}, "token": {},
|
||||
}
|
||||
for _, token := range tokens {
|
||||
if _, unsafe := unsafeTokens[token]; unsafe {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for index := 0; index+1 < len(tokens); index++ {
|
||||
pair := tokens[index] + "." + tokens[index+1]
|
||||
switch pair {
|
||||
case "absolute.path", "access.key", "api.key", "component.key", "database.query", "direct.socket", "file.path", "host.path", "private.key", "raw.path", "run.direct", "run.socket", "unix.socket":
|
||||
return true
|
||||
}
|
||||
}
|
||||
tokenSet := make(map[string]struct{}, len(tokens))
|
||||
for _, token := range tokens {
|
||||
tokenSet[token] = struct{}{}
|
||||
}
|
||||
if _, hasSQL := tokenSet["sql"]; hasSQL {
|
||||
for _, token := range []string{"query", "statement", "raw"} {
|
||||
if _, unsafe := tokenSet[token]; unsafe {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func recordRuntimeProfileKey(seen map[string]struct{}, field, key string) []string {
|
||||
if key == "" {
|
||||
return nil
|
||||
@@ -382,6 +467,20 @@ func validateRuntimeProfileCapabilityDeclarations(profiles domain.GamePluginRunt
|
||||
return violations
|
||||
}
|
||||
|
||||
func validateRuntimeLogEventPermissionDeclarations(field string, profiles domain.GamePluginRuntimeProfiles, declared []string) []string {
|
||||
declaredSet := make(map[string]struct{}, len(declared))
|
||||
for _, permission := range declared {
|
||||
declaredSet[permission] = struct{}{}
|
||||
}
|
||||
var violations []string
|
||||
for i, event := range profiles.LogEvents {
|
||||
if _, exists := declaredSet[event.Permission]; !exists {
|
||||
violations = append(violations, fmt.Sprintf("%s[%d].permission must be declared by the plugin", field, i))
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
func validateLifecycleActionsOptional(actions domain.PluginLifecycleActions) []string {
|
||||
var violations []string
|
||||
for field, value := range map[string]string{"install": actions.Install, "start": actions.Start, "stop": actions.Stop, "restart": actions.Restart, "status": actions.Status} {
|
||||
|
||||
Reference in New Issue
Block a user