Files
run/protocol/job_validation.go
T

524 lines
22 KiB
Go

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.FileTargetKey != "" && !ValidLogicalFileKey(assignment.ExecutionInput.FileTargetKey) {
return ValidationError("execution input fileTargetKey is not allowed")
}
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.TargetExecutableChecksum) || 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
}
}