package protocol import ( "encoding/hex" "net" "net/url" "strings" "time" "unicode/utf8" ) const maxRunLogicalFileKeyLength = 160 const maxRunExecutionContentBytes = 64 * 1024 const maxRunDLLExtensionBytes = int64(128 * 1024 * 1024) const maxSourceRCONTimeoutSeconds = 60 const maxProtectedRequestTimeoutSeconds = 120 const maxProtectedRequestTextBytes = 16 * 1024 const ( maxSQLiteSchemaProbeObjects = 512 maxSQLiteSchemaProbeColumnsPerObject = 256 maxSQLiteSchemaProbeIndexesPerObject = 128 maxSQLiteSchemaProbeForeignKeys = 128 maxSQLiteSchemaProbeCardinalityReads = 512 maxSQLiteSchemaProbeSamples = 3 maxSQLiteSchemaProbeTimeoutMS = 10000 maxSQLiteSchemaProbeResultBytes = 1024 * 1024 ) func ValidateRunJobAssignment(assignment RunJobAssignment) error { if assignment.JobID == "" || assignment.RunEndpointID == "" || assignment.Capability == "" { return ValidationError("jobId, runEndpointId, and capability are required") } switch assignment.Capability { case RunCapabilityConfigWrite, RunCapabilityFilesList, RunCapabilityFilesRead, RunCapabilityFilesWrite: if assignment.ServerInstanceID == "" { return ValidationError("serverInstanceId is required for scoped file jobs") } if !ValidLogicalFileKey(assignment.TargetKey) { return ValidationError("targetKey is not allowed") } } switch assignment.Capability { case RunCapabilityConfigWrite, RunCapabilityFilesWrite: if !ValidScopedInputRef(assignment.InputRef) { return ValidationError("inputRef is not allowed") } } if len([]byte(assignment.ExecutionInput.Content)) > maxRunExecutionContentBytes { return ValidationError("execution input content is too large") } if assignment.ExecutionInput.MaxReadBytes < 0 || assignment.ExecutionInput.MaxReadBytes > maxRunExecutionContentBytes { return ValidationError("execution input maxReadBytes is out of bounds") } if assignment.ExecutionInput.WorkspaceScope != "" && !ValidLogicalFileKey(assignment.ExecutionInput.WorkspaceScope) { return ValidationError("execution input workspaceScope is not allowed") } if assignment.ExecutionInput.ServerDeploymentPlan != nil { return ValidationError("game-specific server deployment plans are legacy unsupported input") } if len(assignment.ExecutionInput.DLLExtensions) > 0 { if assignment.Capability != RunCapabilityProcessStart || assignment.ServerInstanceID == "" || assignment.ExecutionInput.WorkspaceScope == "" { return ValidationError("DLL extensions are allowed only for scoped process.start jobs") } if len(assignment.ExecutionInput.DLLExtensions) > 16 { return ValidationError("too many DLL extensions are declared") } keys := make(map[string]struct{}, len(assignment.ExecutionInput.DLLExtensions)) targets := make(map[string]struct{}, len(assignment.ExecutionInput.DLLExtensions)) for _, plan := range assignment.ExecutionInput.DLLExtensions { if err := validateRuntimeDLLExtensionPlan(plan); err != nil { return err } if _, exists := keys[plan.Key]; exists { return ValidationError("DLL extension key is duplicated") } if _, exists := targets[plan.TargetKey]; exists { return ValidationError("DLL extension target is duplicated") } keys[plan.Key] = struct{}{} targets[plan.TargetKey] = struct{}{} } } if assignment.ExecutionInput.LogSource != nil { if assignment.Capability != RunCapabilityLogsBackfill || assignment.ServerInstanceID == "" { return ValidationError("log source is allowed only for logs.backfill jobs") } if err := validateRuntimeLogSourcePlan(*assignment.ExecutionInput.LogSource); err != nil { return err } } if len(assignment.ExecutionInput.LogSources) > 0 { if assignment.Capability != RunCapabilityProcessStart || assignment.ServerInstanceID == "" { return ValidationError("process log sources are allowed only for process.start jobs") } seen := map[string]struct{}{} for _, source := range assignment.ExecutionInput.LogSources { if err := validateRuntimeProcessLogSourcePlan(source); err != nil { return err } if _, exists := seen[source.Kind]; exists { return ValidationError("process log source kind is duplicated") } seen[source.Kind] = struct{}{} } } if assignment.ExecutionInput.SourceRCON != nil { isSourceCommand := assignment.Capability == RunCapabilityRemoteRunRCONCommand isProtectedRCON := assignment.Capability == RunCapabilityRemoteRunProtectedRCON if (!isSourceCommand && !isProtectedRCON) || assignment.ServerInstanceID == "" || assignment.ExecutionInput.WorkspaceScope == "" { return ValidationError("Source RCON is allowed only for scoped remote.run.rcon.command or remote.run.protected.rcon jobs") } if assignment.MaxAttempts != 1 { return ValidationError("Source RCON jobs must have exactly one attempt") } if isSourceCommand && !strings.HasPrefix(assignment.InputRef, "input://source-rcon/") { return ValidationError("Source RCON inputRef must be a source-rcon input ref") } if isProtectedRCON && !strings.HasPrefix(assignment.InputRef, "input://protected-request/") { return ValidationError("protected Source RCON inputRef must be a protected-request input ref") } wantAdapterKind := "rcon" if isProtectedRCON { wantAdapterKind = protectedRequestAdapterKind(assignment.Capability) } if assignment.ExecutionInput.RemoteAdapterKind != "" && assignment.ExecutionInput.RemoteAdapterKind != wantAdapterKind { return ValidationError("Source RCON requires the rcon adapter kind") } maxTimeout := maxSourceRCONTimeoutSeconds if isProtectedRCON { maxTimeout = maxProtectedRequestTimeoutSeconds } if assignment.ExecutionInput.TimeoutSeconds < 1 || assignment.ExecutionInput.TimeoutSeconds > maxTimeout { return ValidationError("Source RCON timeout is out of bounds") } if err := validateRuntimeSourceRCONPlan(*assignment.ExecutionInput.SourceRCON); err != nil { return err } } if assignment.ExecutionInput.SQLiteSchemaProbe != nil { if assignment.Capability != RunCapabilityRemoteRunDBSQLiteProbe || assignment.ServerInstanceID == "" || assignment.ExecutionInput.WorkspaceScope == "" || assignment.MaxAttempts != 1 || assignment.FencingToken == 0 { return ValidationError("SQLite schema probe requires a scoped single fenced probe job") } if assignment.InputRef != "" || assignment.ExecutionInput.RemoteAdapterKey != "" || assignment.ExecutionInput.RemoteAdapterKind != "" || assignment.ExecutionInput.Content != "" || len(assignment.ExecutionInput.Inputs) != 0 { return ValidationError("SQLite schema probe must not carry adapter input or content") } if !ValidLogicalFileKey(assignment.TargetKey) || !strings.HasPrefix(assignment.TargetKey, "databases/") { return ValidationError("SQLite schema probe target must be a logical database target") } if err := validateSQLiteSchemaProbeRequest(*assignment.ExecutionInput.SQLiteSchemaProbe, assignment); err != nil { return err } } if isProtectedRequestCapability(assignment.Capability) { if assignment.ServerInstanceID == "" || assignment.MaxAttempts != 1 || assignment.FencingToken == 0 { return ValidationError("protected requests require a scoped single fenced attempt") } if !strings.HasPrefix(assignment.InputRef, "input://protected-request/") { return ValidationError("protected request inputRef is not allowed") } if !ValidLogicalFileKey(assignment.TargetKey) || !ValidLogicalFileKey(assignment.ExecutionInput.RemoteAdapterKey) { return ValidationError("protected request logical binding is not allowed") } if assignment.ExecutionInput.RemoteAdapterKind != protectedRequestAdapterKind(assignment.Capability) { return ValidationError("protected request adapter kind does not match capability") } if assignment.ExecutionInput.TimeoutSeconds < 1 || assignment.ExecutionInput.TimeoutSeconds > maxProtectedRequestTimeoutSeconds { return ValidationError("protected request timeout is out of bounds") } if assignment.ExecutionInput.Content != "" { return ValidationError("protected request text must not be in a job assignment") } } if strings.HasPrefix(assignment.InputRef, "input://source-rcon/") && assignment.ExecutionInput.SourceRCON == nil { return ValidationError("source-rcon inputRef requires a Source RCON plan") } if IsRemoteCapability(assignment.Capability) { if assignment.ServerInstanceID == "" { return ValidationError("serverInstanceId is required for remote jobs") } if RemoteCapabilityRequiresTargetKey(assignment.Capability) && !ValidLogicalFileKey(assignment.TargetKey) { return ValidationError("targetKey is not allowed") } if RemoteCapabilityRequiresInputRef(assignment.Capability) && !ValidScopedInputRef(assignment.InputRef) { return ValidationError("inputRef is not allowed") } if assignment.ExecutionInput.RemoteAdapterKey != "" && !ValidLogicalFileKey(assignment.ExecutionInput.RemoteAdapterKey) { return ValidationError("remoteAdapterKey is not allowed") } if assignment.ExecutionInput.RemoteAdapterKind != "" && !validRemoteAdapterKind(assignment.ExecutionInput.RemoteAdapterKind) { return ValidationError("remoteAdapterKind is not allowed") } if assignment.ExecutionInput.TimeoutSeconds < 0 || assignment.ExecutionInput.TimeoutSeconds > 300 { return ValidationError("remote adapter timeout is out of bounds") } } if assignment.Capability == RunCapabilityRemoteRunDBSQLiteProbe && assignment.ExecutionInput.SQLiteSchemaProbe == nil { return ValidationError("SQLite schema probe request is required") } switch assignment.Capability { case RunCapabilityDistributionBuild: if assignment.ServerInstanceID == "" { return ValidationError("serverInstanceId is required for distribution build jobs") } if !ValidLogicalFileKey(assignment.TargetKey) || !strings.HasPrefix(assignment.TargetKey, "distribution/") { return ValidationError("targetKey is not allowed for distribution build jobs") } if !ValidScopedInputRef(assignment.InputRef) || !strings.HasPrefix(assignment.InputRef, "input://distribution-build/") { return ValidationError("inputRef must be a distribution build input ref") } case RunCapabilityRunSelfUpdate: if assignment.ServerInstanceID == "" { return ValidationError("serverInstanceId is required for self-update jobs") } if assignment.TargetKey != "run/update" { return ValidationError("targetKey must be run/update") } if !ValidScopedInputRef(assignment.InputRef) || !strings.HasPrefix(assignment.InputRef, "artifact://") { return ValidationError("inputRef must be an artifact ref for self-update") } case RunCapabilityDependenciesCheck, RunCapabilityDependenciesInstall: if assignment.ServerInstanceID == "" { return ValidationError("serverInstanceId is required for dependency jobs") } if !ValidLogicalFileKey(assignment.TargetKey) || !strings.HasPrefix(assignment.TargetKey, "dependencies/") { return ValidationError("targetKey is not allowed for dependency jobs") } if assignment.InputRef != "" { return ValidationError("dependency jobs must not carry arbitrary input refs") } case RunCapabilityLogsBackfill: if assignment.ServerInstanceID == "" { return ValidationError("serverInstanceId is required for log backfill jobs") } if !ValidLogicalFileKey(assignment.TargetKey) || !strings.HasPrefix(assignment.TargetKey, "logs/") { return ValidationError("targetKey is not allowed for log backfill jobs") } if assignment.InputRef != "" && !ValidScopedInputRef(assignment.InputRef) { return ValidationError("inputRef is not allowed for log backfill jobs") } } return nil } func ValidProtectedRequestExecutionInput(value ProtectedRequestExecutionInputResponse) bool { return value.JobID != "" && value.ServerInstanceID != "" && value.RunEndpointID != "" && value.FencingToken != 0 && value.Authorized && value.ApprovalState == "approved" && value.QueueState == "claimed" && !value.ExpiresAt.IsZero() && time.Now().UTC().Before(value.ExpiresAt) && validProtectedRequestKind(value.Kind) && ValidLogicalFileKey(value.TransportKey) && ValidLogicalFileKey(value.TargetKey) && validProtectedRequestText(value.RequestText) } func validProtectedRequestText(value string) bool { return strings.TrimSpace(value) != "" && utf8.ValidString(value) && len([]byte(value)) <= maxProtectedRequestTextBytes && !strings.ContainsRune(value, '\x00') } func isProtectedRequestCapability(capability string) bool { return protectedRequestAdapterKind(capability) != "" } func IsProtectedRequestCapability(capability string) bool { return isProtectedRequestCapability(capability) } func protectedRequestAdapterKind(capability string) string { switch capability { case RunCapabilityRemoteRunProtectedSQL: return "protected-sql" case RunCapabilityRemoteRunProtectedRCON: return "protected-rcon" case RunCapabilityRemoteRunProgram: return "protected-program" default: return "" } } func validProtectedRequestKind(kind string) bool { return kind == "sql" || kind == "rcon" || kind == "program" } func validRemoteAdapterKind(kind string) bool { switch kind { case "ftp", "rsync", "run-file", "run-process", "database", "rcon", "log-transfer", "protected-sql", "protected-rcon", "protected-program": return true default: return false } } func validateSQLiteSchemaProbeRequest(request SQLiteSchemaProbeRequest, assignment RunJobAssignment) error { if !validProbeIdentifier(request.RequestID) { return ValidationError("SQLite schema probe requestId is not allowed") } binding := request.Binding if binding.ServerInstanceID != assignment.ServerInstanceID || binding.RunEndpointID != assignment.RunEndpointID || !validProbeIdentifier(binding.RunBindingID) || !validProbeIdentifier(binding.PluginID) || !validProbeIdentifier(binding.PluginVersion) || !validProbeIdentifier(binding.AdapterVersion) || !validProbeIdentifier(binding.DatabaseIdentity) { return ValidationError("SQLite schema probe binding is invalid") } if binding.GameVersion != "" && !validProbeIdentifier(binding.GameVersion) { return ValidationError("SQLite schema probe gameVersion is invalid") } if err := validateSQLiteSchemaProbeLimits(request.Limits); err != nil { return err } return nil } func validateSQLiteSchemaProbeLimits(limits SQLiteSchemaProbeLimits) error { if limits.MaxObjects < 1 || limits.MaxObjects > maxSQLiteSchemaProbeObjects || limits.MaxColumnsPerObject < 1 || limits.MaxColumnsPerObject > maxSQLiteSchemaProbeColumnsPerObject || limits.MaxIndexesPerObject < 0 || limits.MaxIndexesPerObject > maxSQLiteSchemaProbeIndexesPerObject || limits.MaxForeignKeys < 0 || limits.MaxForeignKeys > maxSQLiteSchemaProbeForeignKeys || limits.MaxCardinalityReads < 0 || limits.MaxCardinalityReads > maxSQLiteSchemaProbeCardinalityReads || limits.MaxSampleRows < 0 || limits.MaxSampleRows > maxSQLiteSchemaProbeSamples || limits.TimeoutMS < 1 || limits.TimeoutMS > maxSQLiteSchemaProbeTimeoutMS || limits.MaxResultBytes < 1 || limits.MaxResultBytes > maxSQLiteSchemaProbeResultBytes { return ValidationError("SQLite schema probe limits are out of bounds") } return nil } func validProbeIdentifier(value string) bool { return ValidLogicalFileKey(value) && !strings.Contains(value, "/") } func validateRuntimeDLLExtensionPlan(plan RuntimeDLLExtensionPlan) error { if !ValidLogicalFileKey(plan.Key) || !ValidLogicalFileKey(plan.TargetKey) || !validExtensionVersion(plan.Version) { return ValidationError("DLL extension identity is not allowed") } if !validRuntimeDLLURL(plan.ReleaseURL) || !validSHA256(plan.Checksum) || !validSHA256(plan.SCUMExecutableChecksum) || plan.SizeBytes < 1 || plan.SizeBytes > maxRunDLLExtensionBytes { return ValidationError("DLL extension release integrity is not allowed") } if !validDLLModKey(plan.ModKey) || plan.DLLRef != "ue4ss/Mods/"+plan.ModKey+"/dlls/main.dll" { return ValidationError("DLL extension deployment path is not allowed") } if !validUE4SSABI(plan.UE4SSABI) || plan.RCONPort < 1024 || plan.RCONPort > 65535 { return ValidationError("DLL extension compatibility metadata is not allowed") } return nil } func validateRuntimeLogSourcePlan(plan RuntimeLogSourcePlan) error { if !ValidLogicalFileKey(plan.Key) || !ValidLogicalFileKey(plan.StreamKey) || !ValidLogicalFileKey(plan.TargetKey) { return ValidationError("log source identity is not allowed") } if plan.Kind != "file.tail" { return ValidationError("log source kind is unsupported") } switch plan.CursorKind { case "", "offset", "fingerprint": default: return ValidationError("log source cursor kind is unsupported") } if plan.RetentionDays < 0 || plan.RetentionDays > 365 { return ValidationError("log source retention is out of bounds") } return nil } func validateRuntimeProcessLogSourcePlan(plan RuntimeLogSourcePlan) error { if !ValidLogicalFileKey(plan.Key) || !ValidLogicalFileKey(plan.StreamKey) { return ValidationError("process log source identity is not allowed") } switch plan.Kind { case "process.stdout", "process.stderr": default: return ValidationError("process log source kind is unsupported") } if plan.TargetKey != "" && !ValidLogicalFileKey(plan.TargetKey) { return ValidationError("process log source target is not allowed") } switch plan.CursorKind { case "", "sequence": default: return ValidationError("process log source cursor kind is unsupported") } if plan.RetentionDays < 0 || plan.RetentionDays > 365 { return ValidationError("process log source retention is out of bounds") } return nil } func validateRuntimeSourceRCONPlan(plan RuntimeSourceRCONPlan) error { if plan.Protocol != "source-rcon" || !ValidLogicalFileKey(plan.ExtensionKey) || !validDLLModKey(plan.ModKey) { return ValidationError("Source RCON identity is not allowed") } if plan.ConfigRef != "ue4ss/Mods/"+plan.ModKey+"/config.ini" || !ValidLogicalFileKey(plan.ConfigRef) { return ValidationError("Source RCON config reference is not allowed") } if !validSourceRCONDeploymentStateRef(plan.DeploymentStateRef) { return ValidationError("Source RCON deployment state reference is not allowed") } if plan.Port < 1024 || plan.Port > 65535 { return ValidationError("Source RCON port is not allowed") } return nil } func validSourceRCONDeploymentStateRef(value string) bool { const prefix = "runtime/ue4ss-dll/" const suffix = "/release.json" if !strings.HasPrefix(value, prefix) || !strings.HasSuffix(value, suffix) { return false } targetKey := strings.TrimSuffix(strings.TrimPrefix(value, prefix), suffix) return targetKey != "" && ValidLogicalFileKey(targetKey) } func validRuntimeDLLURL(value string) bool { parsed, err := url.ParseRequestURI(value) if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil || parsed.Fragment != "" || parsed.Port() != "" && parsed.Port() != "443" || parsed.RawQuery != "" || !strings.HasSuffix(strings.ToLower(parsed.Path), ".dll") { return false } host := strings.ToLower(parsed.Hostname()) if host == "localhost" || strings.HasSuffix(host, ".localhost") || strings.HasSuffix(host, ".local") { return false } if ip := net.ParseIP(host); ip != nil && (ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsLinkLocalUnicast()) { return false } return true } func validSHA256(value string) bool { if len(value) != len("sha256:")+64 || !strings.HasPrefix(value, "sha256:") { return false } _, err := hex.DecodeString(strings.TrimPrefix(value, "sha256:")) return err == nil } func validExtensionVersion(value string) bool { if len(value) == 0 || len(value) > 80 { return false } for _, char := range value { if char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || char == '.' || char == '-' { continue } return false } return true } func validDLLModKey(value string) bool { if len(value) == 0 || len(value) > 80 { return false } for index, char := range value { if char >= 'a' && char <= 'z' || char >= '0' && char <= '9' || char == '_' || char == '-' { if index > 0 || char != '_' && char != '-' { continue } } return false } return true } func validUE4SSABI(value string) bool { if len(value) == 0 || len(value) > 80 { return false } for _, char := range value { if char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || char == '.' || char == '_' || char == '-' { continue } return false } return true } type ValidationError string func (err ValidationError) Error() string { return string(err) } func ValidLogicalFileKey(key string) bool { trimmed := strings.TrimSpace(key) if trimmed == "" || trimmed != key || len([]rune(key)) > maxRunLogicalFileKeyLength { return false } lower := strings.ToLower(key) if strings.HasPrefix(key, "/") || strings.Contains(key, "..") || strings.Contains(key, `\`) || strings.Contains(key, "://") || strings.Contains(lower, "/users/") || strings.Contains(lower, "password=") || strings.Contains(lower, "secret=") || strings.Contains(lower, "sk-") || strings.Contains(lower, "bearer ") { return false } for _, char := range key { if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' || char == '-' || char == '.' || char == '/' { continue } return false } return true } func ValidScopedInputRef(ref string) bool { trimmed := strings.TrimSpace(ref) lower := strings.ToLower(ref) if trimmed == "" || trimmed != ref || strings.Contains(lower, "/users/") || strings.Contains(lower, "password=") || strings.Contains(lower, "secret=") || strings.Contains(lower, "sk-") || strings.Contains(lower, "bearer ") { return false } return strings.HasPrefix(ref, "input://") || strings.HasPrefix(ref, "artifact://") } func IsRemoteCapability(capability string) bool { return strings.HasPrefix(capability, "remote.") } func RemoteCapabilityRequiresTargetKey(capability string) bool { switch capability { case RunCapabilityRemoteRunProcessStart, RunCapabilityRemoteRunProcessStop: return false default: return IsRemoteCapability(capability) } } func RemoteCapabilityRequiresInputRef(capability string) bool { switch capability { case RunCapabilityRemoteFTPWrite, RunCapabilityRemoteRsyncWrite, RunCapabilityRemoteRunFilesWrite, RunCapabilityRemoteRunDBMySQLQuery, RunCapabilityRemoteRunDBSQLiteQuery, RunCapabilityRemoteRunRCONCommand, RunCapabilityRemoteRunProtectedSQL, RunCapabilityRemoteRunProtectedRCON, RunCapabilityRemoteRunProgram: return true default: return false } }