功能修改

This commit is contained in:
npc0-hue
2026-07-20 16:42:33 +08:00
parent 48b8ad8d6c
commit a0e69417db
224 changed files with 22015 additions and 884 deletions
@@ -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} {
+6 -1
View File
@@ -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
}
+427
View File
@@ -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)
}
})
}
}
+23
View File
@@ -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
}
+250
View File
@@ -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=")
}
+292 -7
View File
@@ -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)
+102 -1
View File
@@ -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"}},
},
}
}
+3
View File
@@ -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)
}
}
+99
View File
@@ -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} {