1551 lines
60 KiB
Go
1551 lines
60 KiB
Go
package validator
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"browser.local/platform/domain"
|
|
)
|
|
|
|
const (
|
|
maxAuditSummaryLength = 512
|
|
maxContactNoteLength = 160
|
|
maxMarketplaceKeywordSize = 80
|
|
maxMarketplaceListSize = 500
|
|
maxPluginDescriptionLength = 240
|
|
maxPluginPageTitleLength = 40
|
|
maxPluginBridgePayloadKeys = 16
|
|
maxPluginBridgePayloadSize = 4096
|
|
maxProgressMessageLength = 256
|
|
maxServerConfigContentSize = 64 * 1024
|
|
maxLogicalFileKeyLength = 160
|
|
)
|
|
|
|
type ValidationError struct {
|
|
Violations []string
|
|
}
|
|
|
|
func (err ValidationError) Error() string {
|
|
return "validation failed: " + strings.Join(err.Violations, "; ")
|
|
}
|
|
|
|
func (err ValidationError) IsEmpty() bool {
|
|
return len(err.Violations) == 0
|
|
}
|
|
|
|
func appendRequired(violations []string, field string, value string) []string {
|
|
if strings.TrimSpace(value) == "" {
|
|
return append(violations, field+" is required")
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func finish(violations []string) error {
|
|
if len(violations) == 0 {
|
|
return nil
|
|
}
|
|
return ValidationError{Violations: violations}
|
|
}
|
|
|
|
func ValidateUser(user domain.User) error {
|
|
var violations []string
|
|
violations = appendRequired(violations, "id", user.ID)
|
|
violations = appendRequired(violations, "displayName", user.DisplayName)
|
|
if !validUserStatus(user.Status) {
|
|
violations = append(violations, "status is invalid")
|
|
}
|
|
if strings.TrimSpace(user.Email) != "" && !strings.Contains(user.Email, "@") {
|
|
violations = append(violations, "email is invalid")
|
|
}
|
|
if len(user.Profile.ContactNote) > maxContactNoteLength {
|
|
violations = append(violations, "profile.contactNote is too long")
|
|
}
|
|
for i, role := range user.Roles {
|
|
if strings.TrimSpace(role) == "" {
|
|
violations = append(violations, fmt.Sprintf("roles[%d] is required", i))
|
|
}
|
|
if !validUserRole(role) {
|
|
violations = append(violations, fmt.Sprintf("roles[%d] is invalid", i))
|
|
}
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidateAIProvider(provider domain.AIProvider) error {
|
|
var violations []string
|
|
violations = appendRequired(violations, "id", provider.ID)
|
|
violations = appendRequired(violations, "name", provider.Name)
|
|
violations = appendRequired(violations, "baseUrl", provider.BaseURL)
|
|
if !validAIProviderKind(provider.Kind) {
|
|
violations = append(violations, "kind is invalid")
|
|
}
|
|
if !validAIRelayMode(provider.RelayMode) {
|
|
violations = append(violations, "relayMode is invalid")
|
|
}
|
|
if !validAIProviderStatus(provider.Status) {
|
|
violations = append(violations, "status is invalid")
|
|
}
|
|
if provider.RelayMode == domain.AIRelayModeDirect || provider.RelayMode == domain.AIRelayModeRelay {
|
|
violations = appendRequired(violations, "apiKeyRef", provider.APIKeyRef)
|
|
}
|
|
if looksLikeRawSecret(provider.APIKeyRef) {
|
|
violations = append(violations, "apiKeyRef must reference secret storage, not raw key material")
|
|
}
|
|
violations = appendRequired(violations, "redactionPolicy", provider.RedactionPolicy)
|
|
if provider.TimeoutMS <= 0 {
|
|
violations = append(violations, "timeoutMs must be positive")
|
|
}
|
|
if len(provider.Models) == 0 {
|
|
violations = append(violations, "models must not be empty")
|
|
}
|
|
modelSet := map[string]struct{}{}
|
|
for i, model := range provider.Models {
|
|
model = strings.TrimSpace(model)
|
|
if model == "" {
|
|
violations = append(violations, fmt.Sprintf("models[%d] is required", i))
|
|
continue
|
|
}
|
|
if _, exists := modelSet[model]; exists {
|
|
violations = append(violations, fmt.Sprintf("models[%d] duplicates %q", i, model))
|
|
}
|
|
modelSet[model] = struct{}{}
|
|
}
|
|
if provider.DefaultModel != "" {
|
|
if _, exists := modelSet[provider.DefaultModel]; !exists {
|
|
violations = append(violations, "defaultModel must be included in models")
|
|
}
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidateGamePlugin(plugin domain.GamePlugin) error {
|
|
var violations []string
|
|
violations = appendRequired(violations, "id", plugin.ID)
|
|
violations = appendRequired(violations, "name", plugin.Name)
|
|
if len(plugin.Description) > maxPluginDescriptionLength {
|
|
violations = append(violations, "description is too long")
|
|
}
|
|
violations = appendRequired(violations, "version", plugin.Version)
|
|
violations = appendRequired(violations, "serverType", plugin.ServerType)
|
|
violations = appendRequired(violations, "manifestRef", plugin.ManifestRef)
|
|
violations = appendRequired(violations, "createFormSchemaRef", plugin.CreateFormSchemaRef)
|
|
if !validGamePluginStatus(plugin.Status) {
|
|
violations = append(violations, "status is invalid")
|
|
}
|
|
for i, capability := range plugin.RequiredRunCapabilities {
|
|
if strings.TrimSpace(capability) == "" {
|
|
violations = append(violations, fmt.Sprintf("requiredRunCapabilities[%d] is required", i))
|
|
} else if !validPluginRunCapability(capability) {
|
|
violations = append(violations, fmt.Sprintf("requiredRunCapabilities[%d] is not allowed", i))
|
|
}
|
|
}
|
|
violations = append(violations, duplicateViolations("requiredRunCapabilities", plugin.RequiredRunCapabilities)...)
|
|
violations = append(violations, validateDeclaredPluginPermissions(plugin.DeclaredPermissions)...)
|
|
violations = append(violations, validateBridgeActions("bridgeActions", plugin.BridgeActions)...)
|
|
violations = append(violations, validatePluginPages(plugin.Pages)...)
|
|
violations = append(violations, duplicateViolations("tags", plugin.Tags)...)
|
|
violations = append(violations, validateAIPurposes(plugin.AIPurposes)...)
|
|
violations = append(violations, validateRemoteAccess("remoteAccess", plugin.RemoteAccess, plugin.RequiredRunCapabilities)...)
|
|
violations = append(violations, validateSafePluginStrings("gamePlugin", pluginSafeStrings(plugin))...)
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidateGamePluginManifestRegistration(registration domain.GamePluginManifestRegistration) error {
|
|
registration = domain.CopyGamePluginManifestRegistration(registration)
|
|
manifest := registration.Manifest
|
|
var violations []string
|
|
violations = appendRequired(violations, "manifestRef", registration.ManifestRef)
|
|
if !safeRef(registration.ManifestRef) {
|
|
violations = append(violations, "manifestRef is unsafe")
|
|
}
|
|
violations = appendRequired(violations, "manifest.id", manifest.ID)
|
|
violations = appendRequired(violations, "manifest.name", manifest.Name)
|
|
if len(manifest.Description) > maxPluginDescriptionLength {
|
|
violations = append(violations, "manifest.description is too long")
|
|
}
|
|
violations = appendRequired(violations, "manifest.version", manifest.Version)
|
|
if manifest.Kind != "game-plugin" {
|
|
violations = append(violations, "manifest.kind must be game-plugin")
|
|
}
|
|
violations = appendRequired(violations, "manifest.server.type", manifest.Server.Type)
|
|
violations = appendRequired(violations, "manifest.server.displayName", manifest.Server.DisplayName)
|
|
violations = appendRequired(violations, "manifest.server.createFormSchema", manifest.Server.CreateFormSchema)
|
|
if !safeRelativeJSONRef(manifest.Server.CreateFormSchema) {
|
|
violations = append(violations, "manifest.server.createFormSchema must be a safe relative JSON reference")
|
|
}
|
|
for i, osName := range manifest.Server.SupportedOS {
|
|
if !validPluginSupportedOS(osName) {
|
|
violations = append(violations, fmt.Sprintf("manifest.server.supportedOs[%d] is not allowed", i))
|
|
}
|
|
}
|
|
violations = append(violations, duplicateViolations("manifest.server.supportedOs", manifest.Server.SupportedOS)...)
|
|
for i, capability := range manifest.Capabilities {
|
|
if !validPluginRunCapability(capability) {
|
|
violations = append(violations, fmt.Sprintf("manifest.capabilities[%d] is not allowed", i))
|
|
}
|
|
}
|
|
if len(manifest.Capabilities) == 0 {
|
|
violations = append(violations, "manifest.capabilities must not be empty")
|
|
}
|
|
violations = append(violations, duplicateViolations("manifest.capabilities", manifest.Capabilities)...)
|
|
if len(manifest.Permissions) == 0 {
|
|
violations = append(violations, "manifest.permissions must not be empty")
|
|
}
|
|
violations = append(violations, validateDeclaredPluginPermissions(manifest.Permissions)...)
|
|
violations = append(violations, validateBridgeActions("manifest.bridge.actions", manifest.Bridge.Actions)...)
|
|
violations = append(violations, validateLifecycleActions(manifest.Actions)...)
|
|
violations = append(violations, validatePluginPages(manifest.Pages)...)
|
|
violations = append(violations, duplicateViolations("manifest.tags", manifest.Tags)...)
|
|
violations = append(violations, validateAIPurposes(manifest.AI.Purposes)...)
|
|
violations = append(violations, validateRemoteAccess("manifest.remoteAccess", manifest.RemoteAccess, manifest.Capabilities)...)
|
|
violations = append(violations, validateSafePluginStrings("manifest", manifestSafeStrings(registration))...)
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidatePluginBridgeAuthorizeRequest(request domain.PluginBridgeAuthorizeRequest) error {
|
|
var violations []string
|
|
violations = appendRequired(violations, "pluginId", request.PluginID)
|
|
violations = appendRequired(violations, "routeKey", request.RouteKey)
|
|
violations = appendRequired(violations, "action", string(request.Action))
|
|
if !validPluginBridgeAction(request.Action) {
|
|
violations = append(violations, "action is not supported")
|
|
}
|
|
if request.Action == domain.PluginBridgeActionAIInvoke {
|
|
violations = appendRequired(violations, "aiPurpose", request.AIPurpose)
|
|
if strings.TrimSpace(request.AIPurpose) != "" && !validAIPurpose(request.AIPurpose) {
|
|
violations = append(violations, "aiPurpose is not allowed")
|
|
}
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidatePluginBridgeExecuteRequest(request domain.PluginBridgeExecuteRequest) error {
|
|
request = domain.CopyPluginBridgeExecuteRequest(request)
|
|
var violations []string
|
|
violations = appendRequired(violations, "requestId", request.RequestID)
|
|
violations = appendRequired(violations, "pluginId", request.PluginID)
|
|
violations = appendRequired(violations, "routeKey", request.RouteKey)
|
|
violations = appendRequired(violations, "action", string(request.Action))
|
|
if !validPluginBridgeAction(request.Action) {
|
|
violations = append(violations, "action is not supported")
|
|
}
|
|
if request.Action != domain.PluginBridgeActionAIInvoke {
|
|
violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID)
|
|
}
|
|
if request.Action == domain.PluginBridgeActionAIInvoke {
|
|
violations = appendRequired(violations, "aiPurpose", request.AIPurpose)
|
|
if strings.TrimSpace(request.AIPurpose) != "" && !validAIPurpose(request.AIPurpose) {
|
|
violations = append(violations, "aiPurpose is not allowed")
|
|
}
|
|
}
|
|
if len(request.Payload) > maxPluginBridgePayloadKeys {
|
|
violations = append(violations, "payload has too many keys")
|
|
}
|
|
payloadSize := 0
|
|
for key, value := range request.Payload {
|
|
payloadSize += len(key) + len(value)
|
|
if strings.TrimSpace(key) == "" || strings.TrimSpace(key) != key || len([]rune(key)) > 80 {
|
|
violations = append(violations, "payload key is invalid")
|
|
}
|
|
if len([]rune(value)) > 1024 {
|
|
violations = append(violations, "payload value is too long")
|
|
}
|
|
for _, reason := range unsafePluginStringReasons(key) {
|
|
violations = append(violations, "payload key: "+reason)
|
|
}
|
|
for _, reason := range unsafePluginStringReasons(value) {
|
|
violations = append(violations, "payload."+key+": "+reason)
|
|
}
|
|
if containsUnsafeRuntimeSecret(value) || strings.Contains(strings.ToLower(value), "unix://") || strings.Contains(strings.ToLower(value), "tcp://") {
|
|
violations = append(violations, "payload contains unsafe content")
|
|
}
|
|
}
|
|
if payloadSize > maxPluginBridgePayloadSize {
|
|
violations = append(violations, "payload is too large")
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidatePluginMarketplaceFilter(filter domain.PluginMarketplaceFilter) error {
|
|
var violations []string
|
|
if filter.Status != "" && !validGamePluginStatus(filter.Status) {
|
|
violations = append(violations, "status is invalid")
|
|
}
|
|
if strings.TrimSpace(filter.ServerType) != filter.ServerType {
|
|
violations = append(violations, "serverType must not have surrounding whitespace")
|
|
}
|
|
if strings.TrimSpace(filter.Capability) != filter.Capability {
|
|
violations = append(violations, "capability must not have surrounding whitespace")
|
|
}
|
|
if len([]rune(filter.Keyword)) > maxMarketplaceKeywordSize {
|
|
violations = append(violations, "keyword is too long")
|
|
}
|
|
for _, value := range []fieldString{
|
|
{field: "serverType", value: filter.ServerType},
|
|
{field: "capability", value: filter.Capability},
|
|
{field: "keyword", value: filter.Keyword},
|
|
} {
|
|
for _, reason := range unsafePluginStringReasons(value.value) {
|
|
violations = append(violations, "filter."+value.field+": "+reason)
|
|
}
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidatePluginMarketplaceStateAction(action domain.PluginMarketplaceStateAction) error {
|
|
if validPluginMarketplaceStateAction(action) {
|
|
return nil
|
|
}
|
|
return ValidationError{Violations: []string{"action is not supported"}}
|
|
}
|
|
|
|
func ValidatePluginMarketplacePlugins(plugins []domain.PluginMarketplacePlugin) error {
|
|
var violations []string
|
|
if len(plugins) > maxMarketplaceListSize {
|
|
violations = append(violations, "items is too long")
|
|
}
|
|
for i, plugin := range plugins {
|
|
prefix := fmt.Sprintf("items[%d]", i)
|
|
violations = append(violations, validatePluginMarketplacePlugin(prefix, plugin)...)
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidatePluginMarketplacePlugin(plugin domain.PluginMarketplacePlugin) error {
|
|
return finish(validatePluginMarketplacePlugin("plugin", plugin))
|
|
}
|
|
|
|
func validatePluginMarketplacePlugin(prefix string, plugin domain.PluginMarketplacePlugin) []string {
|
|
var violations []string
|
|
violations = appendRequired(violations, prefix+".id", plugin.ID)
|
|
violations = appendRequired(violations, prefix+".name", plugin.Name)
|
|
violations = appendRequired(violations, prefix+".version", plugin.Version)
|
|
violations = appendRequired(violations, prefix+".serverType", plugin.ServerType)
|
|
violations = appendRequired(violations, prefix+".manifestRef", plugin.ManifestRef)
|
|
violations = appendRequired(violations, prefix+".createFormSchemaRef", plugin.CreateFormSchemaRef)
|
|
violations = appendRequired(violations, prefix+".source", plugin.Source)
|
|
if !validGamePluginStatus(plugin.Status) {
|
|
violations = append(violations, prefix+".status is invalid")
|
|
}
|
|
if len(plugin.Description) > maxPluginDescriptionLength {
|
|
violations = append(violations, prefix+".description is too long")
|
|
}
|
|
if len(plugin.Capabilities) == 0 {
|
|
violations = append(violations, prefix+".capabilities must not be empty")
|
|
}
|
|
for i, capability := range plugin.Capabilities {
|
|
if !validPluginRunCapability(capability) {
|
|
violations = append(violations, fmt.Sprintf("%s.capabilities[%d] is not allowed", prefix, i))
|
|
}
|
|
}
|
|
for i, osName := range plugin.SupportedOS {
|
|
if !validPluginSupportedOS(osName) {
|
|
violations = append(violations, fmt.Sprintf("%s.supportedOs[%d] is not allowed", prefix, i))
|
|
}
|
|
}
|
|
violations = append(violations, duplicateViolations(prefix+".supportedOs", plugin.SupportedOS)...)
|
|
violations = append(violations, duplicateViolations(prefix+".capabilities", plugin.Capabilities)...)
|
|
violations = append(violations, validateDeclaredPluginPermissions(plugin.DeclaredPermissions)...)
|
|
violations = append(violations, validateBridgeActions(prefix+".bridgeActions", plugin.BridgeActions)...)
|
|
violations = append(violations, validatePluginPages(plugin.Pages)...)
|
|
violations = append(violations, duplicateViolations(prefix+".tags", plugin.Tags)...)
|
|
violations = append(violations, validateAIPurposes(plugin.AIPurposes)...)
|
|
violations = append(violations, validateRemoteAccess(prefix+".remoteAccess", plugin.RemoteAccess, plugin.Capabilities)...)
|
|
violations = append(violations, validateSafePluginStrings(prefix, marketplacePluginSafeStrings(plugin))...)
|
|
return violations
|
|
}
|
|
|
|
func AuthorizePluginBridgeAction(plugin domain.GamePlugin, request domain.PluginBridgeAuthorizeRequest) (domain.PluginBridgeAuthorization, error) {
|
|
result := domain.PluginBridgeAuthorization{
|
|
PluginID: request.PluginID,
|
|
RouteKey: request.RouteKey,
|
|
ServerInstanceID: request.ServerInstanceID,
|
|
Action: request.Action,
|
|
RequiredPermissions: requiredBridgePermissions(request.Action),
|
|
EffectivePermissions: effectivePagePermissions(plugin, request.RouteKey),
|
|
}
|
|
if err := ValidateGamePlugin(plugin); err != nil {
|
|
return result, err
|
|
}
|
|
if err := ValidatePluginBridgeAuthorizeRequest(request); err != nil {
|
|
return result, err
|
|
}
|
|
if plugin.ID != request.PluginID {
|
|
result.Reason = "pluginId must match installed plugin"
|
|
return result, nil
|
|
}
|
|
if plugin.Status != domain.GamePluginStatusInstalled {
|
|
result.Reason = "plugin must be installed"
|
|
return result, nil
|
|
}
|
|
if !containsString(plugin.BridgeActions, string(request.Action)) {
|
|
result.Reason = "bridge action is not declared by plugin"
|
|
return result, nil
|
|
}
|
|
page, pageFound := findPluginPage(plugin.Pages, request.RouteKey)
|
|
if !pageFound {
|
|
result.Reason = "routeKey is not declared by plugin"
|
|
return result, nil
|
|
}
|
|
if len(page.BridgeActions) > 0 && !containsString(page.BridgeActions, string(request.Action)) {
|
|
result.Reason = "bridge action is not declared by page"
|
|
return result, nil
|
|
}
|
|
if !containsAll(result.EffectivePermissions, result.RequiredPermissions) {
|
|
result.Reason = "required permission is missing"
|
|
return result, nil
|
|
}
|
|
if request.Action == domain.PluginBridgeActionAIInvoke && !containsString(plugin.AIPurposes, request.AIPurpose) {
|
|
result.Reason = "ai purpose is not declared by plugin"
|
|
return result, nil
|
|
}
|
|
result.Allowed = true
|
|
return result, nil
|
|
}
|
|
|
|
func ValidateServerInstance(instance domain.ServerInstance) error {
|
|
return validateServerInstance(instance, false)
|
|
}
|
|
|
|
func ValidateStoredServerInstance(instance domain.ServerInstance) error {
|
|
return validateServerInstance(instance, true)
|
|
}
|
|
|
|
func validateServerInstance(instance domain.ServerInstance, allowDeleted bool) error {
|
|
var violations []string
|
|
violations = appendRequired(violations, "id", instance.ID)
|
|
violations = appendRequired(violations, "pluginId", instance.PluginID)
|
|
violations = appendRequired(violations, "pluginVersion", instance.PluginVersion)
|
|
violations = appendRequired(violations, "runEndpointId", instance.RunEndpointID)
|
|
violations = appendRequired(violations, "name", instance.Name)
|
|
if strings.TrimSpace(instance.OwnerUserID) != instance.OwnerUserID {
|
|
violations = append(violations, "ownerUserId must not have surrounding whitespace")
|
|
}
|
|
for i, userID := range instance.AdminUserIDs {
|
|
if strings.TrimSpace(userID) == "" {
|
|
violations = append(violations, fmt.Sprintf("adminUserIds[%d] is required", i))
|
|
}
|
|
if strings.TrimSpace(userID) != userID {
|
|
violations = append(violations, fmt.Sprintf("adminUserIds[%d] must not have surrounding whitespace", i))
|
|
}
|
|
if userID == instance.OwnerUserID {
|
|
violations = append(violations, fmt.Sprintf("adminUserIds[%d] must not duplicate ownerUserId", i))
|
|
}
|
|
}
|
|
violations = append(violations, duplicateViolations("adminUserIds", instance.AdminUserIDs)...)
|
|
if !validServerInstanceState(instance.State) {
|
|
violations = append(violations, "state is invalid")
|
|
}
|
|
if instance.State == domain.ServerInstanceStateDeleted && !allowDeleted {
|
|
violations = append(violations, "state must not be deleted on create")
|
|
}
|
|
if instance.ConfigVersion < 0 {
|
|
violations = append(violations, "configVersion must not be negative")
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidateServerInstanceUpdate(update domain.ServerInstanceUpdate) error {
|
|
var violations []string
|
|
if update.Name != nil {
|
|
name := strings.TrimSpace(*update.Name)
|
|
if name == "" {
|
|
violations = append(violations, "name is required")
|
|
}
|
|
if name != *update.Name {
|
|
violations = append(violations, "name must not have surrounding whitespace")
|
|
}
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidateServerInstanceDependencies(instance domain.ServerInstance, plugin domain.GamePlugin, endpoint domain.RunEndpoint) error {
|
|
var violations []string
|
|
if plugin.ID == "" {
|
|
violations = append(violations, "plugin is required")
|
|
} else {
|
|
if plugin.ID != instance.PluginID {
|
|
violations = append(violations, "pluginId must match plugin")
|
|
}
|
|
if plugin.Status != domain.GamePluginStatusInstalled {
|
|
violations = append(violations, "plugin must be installed")
|
|
}
|
|
if plugin.Version != instance.PluginVersion {
|
|
violations = append(violations, "pluginVersion must match plugin")
|
|
}
|
|
}
|
|
if endpoint.ID == "" {
|
|
violations = append(violations, "run endpoint is required")
|
|
} else {
|
|
if endpoint.ID != instance.RunEndpointID {
|
|
violations = append(violations, "runEndpointId must match run endpoint")
|
|
}
|
|
if endpoint.Status != domain.RunEndpointStatusOnline && endpoint.Status != domain.RunEndpointStatusDegraded {
|
|
violations = append(violations, "run endpoint must be online or degraded")
|
|
}
|
|
missing := MissingCapabilities(endpoint.Capabilities, plugin.RequiredRunCapabilities)
|
|
if len(missing) > 0 {
|
|
violations = append(violations, "run endpoint missing required capabilities: "+strings.Join(missing, ", "))
|
|
}
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidatePlatformResourceUsage(usage domain.PlatformResourceUsage) error {
|
|
var violations []string
|
|
violations = appendPercentViolation(violations, "cpuPercent", usage.CPUPercent)
|
|
violations = appendPercentViolation(violations, "memoryPercent", usage.MemoryPercent)
|
|
violations = appendPercentViolation(violations, "diskPercent", usage.DiskPercent)
|
|
violations = appendRequired(violations, "source", usage.Source)
|
|
if usage.CollectedAt.IsZero() {
|
|
violations = append(violations, "collectedAt is required")
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidateServerMetricsList(items []domain.ServerMetrics) error {
|
|
var violations []string
|
|
if len(items) > 1000 {
|
|
violations = append(violations, "items is too long")
|
|
}
|
|
for i, item := range items {
|
|
field := fmt.Sprintf("items[%d]", i)
|
|
if strings.TrimSpace(item.ServerInstanceID) == "" {
|
|
violations = append(violations, field+".serverInstanceId is required")
|
|
}
|
|
if item.CollectedAt.IsZero() {
|
|
violations = append(violations, field+".collectedAt is required")
|
|
}
|
|
if strings.TrimSpace(item.Source) == "" {
|
|
violations = append(violations, field+".source is required")
|
|
}
|
|
violations = appendOptionalPercentViolation(violations, field+".cpuPercent", item.CPUPercent)
|
|
violations = appendOptionalPercentViolation(violations, field+".memoryPercent", item.MemoryPercent)
|
|
violations = appendOptionalPercentViolation(violations, field+".diskPercent", item.DiskPercent)
|
|
if item.PlayerCount != nil && *item.PlayerCount < 0 {
|
|
violations = append(violations, field+".playerCount must not be negative")
|
|
}
|
|
if item.MaxPlayers != nil && *item.MaxPlayers < 0 {
|
|
violations = append(violations, field+".maxPlayers must not be negative")
|
|
}
|
|
if item.TPS != nil && (*item.TPS < 0 || *item.TPS > 100) {
|
|
violations = append(violations, field+".tps must be between 0 and 100")
|
|
}
|
|
if item.LatencyMS != nil && *item.LatencyMS < 0 {
|
|
violations = append(violations, field+".latencyMs must not be negative")
|
|
}
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidateServerConfig(config domain.ServerConfig) error {
|
|
var violations []string
|
|
violations = appendRequired(violations, "serverInstanceId", config.ServerInstanceID)
|
|
violations = appendRequired(violations, "format", config.Format)
|
|
violations = appendRequired(violations, "key", config.Key)
|
|
if config.ConfigVersion <= 0 {
|
|
violations = append(violations, "configVersion must be positive")
|
|
}
|
|
if len([]byte(config.Content)) > maxServerConfigContentSize {
|
|
violations = append(violations, "content is too large")
|
|
}
|
|
if config.UpdatedAt.IsZero() {
|
|
violations = append(violations, "updatedAt is required")
|
|
}
|
|
if containsUnsafeRuntimeSecret(config.Content) {
|
|
violations = append(violations, "content must not expose raw secrets, host paths, or direct sockets")
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidateServerConfigDiffRequest(request domain.ServerConfigDiffRequest) error {
|
|
var violations []string
|
|
violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID)
|
|
violations = appendRequired(violations, "key", request.Key)
|
|
if request.ExpectedConfigVersion <= 0 {
|
|
violations = append(violations, "expectedConfigVersion must be positive")
|
|
}
|
|
if !validLogicalFileKey(request.Key) || !validConfigFileKey(request.Key) {
|
|
violations = append(violations, "key is not allowed")
|
|
}
|
|
if len([]byte(request.ProposedContent)) > maxServerConfigContentSize {
|
|
violations = append(violations, "proposedContent is too large")
|
|
}
|
|
if containsUnsafeRuntimeSecret(request.ProposedContent) {
|
|
violations = append(violations, "proposedContent must not expose raw secrets, host paths, or direct sockets")
|
|
}
|
|
if request.ProposedContentInputRef != "" && !validScopedInputRef(request.ProposedContentInputRef) {
|
|
violations = append(violations, "proposedContentInputRef is not allowed")
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidateServerConfigWriteApproval(approval domain.ServerConfigWriteApproval) error {
|
|
request := domain.ServerConfigDiffRequest{
|
|
ServerInstanceID: approval.ServerInstanceID,
|
|
ExpectedConfigVersion: approval.ExpectedConfigVersion,
|
|
Key: approval.Key,
|
|
ProposedContent: approval.ProposedContent,
|
|
ProposedContentInputRef: approval.ProposedContentInputRef,
|
|
}
|
|
var violations []string
|
|
if err := ValidateServerConfigDiffRequest(request); err != nil {
|
|
if validationErr, ok := err.(ValidationError); ok {
|
|
violations = append(violations, validationErr.Violations...)
|
|
} else {
|
|
violations = append(violations, err.Error())
|
|
}
|
|
}
|
|
violations = appendRequired(violations, "idempotencyKey", approval.IdempotencyKey)
|
|
if containsUnsafeRuntimeSecret(approval.IdempotencyKey) || looksLikeRawHostPath(approval.IdempotencyKey) {
|
|
violations = append(violations, "idempotencyKey is not allowed")
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidateFileOperationDispatchRequest(request domain.FileOperationDispatchRequest) error {
|
|
var violations []string
|
|
violations = appendRequired(violations, "serverInstanceId", request.ServerInstanceID)
|
|
violations = appendRequired(violations, "key", request.Key)
|
|
violations = appendRequired(violations, "idempotencyKey", request.IdempotencyKey)
|
|
if !validFileOperationKind(request.Operation) {
|
|
violations = append(violations, "operation is invalid")
|
|
}
|
|
if !validLogicalFileKey(request.Key) {
|
|
violations = append(violations, "key is not allowed")
|
|
}
|
|
if request.Operation == domain.FileOperationWrite && request.InputRef == "" {
|
|
violations = append(violations, "inputRef is required for writes")
|
|
}
|
|
if request.InputRef != "" && !validScopedInputRef(request.InputRef) {
|
|
violations = append(violations, "inputRef is not allowed")
|
|
}
|
|
if request.ExpectedConfigVersion < 0 {
|
|
violations = append(violations, "expectedConfigVersion must not be negative")
|
|
}
|
|
for _, value := range []string{request.PluginID, request.IdempotencyKey} {
|
|
if containsUnsafeRuntimeSecret(value) || looksLikeRawHostPath(value) || strings.Contains(strings.ToLower(value), "unix://") {
|
|
violations = append(violations, "request contains unsafe content")
|
|
}
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func appendPercentViolation(violations []string, field string, value float64) []string {
|
|
if value < 0 || value > 100 {
|
|
return append(violations, field+" must be between 0 and 100")
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func appendOptionalPercentViolation(violations []string, field string, value *float64) []string {
|
|
if value == nil {
|
|
return violations
|
|
}
|
|
return appendPercentViolation(violations, field, *value)
|
|
}
|
|
|
|
func containsUnsafeRuntimeSecret(value string) bool {
|
|
lower := strings.ToLower(value)
|
|
unsafeFragments := []string{
|
|
"/users/",
|
|
"/var/run/",
|
|
"unix://",
|
|
"tcp://",
|
|
"bearer ",
|
|
"api_key=",
|
|
"apikey=",
|
|
"password=",
|
|
"secret=",
|
|
"sk-",
|
|
}
|
|
for _, fragment := range unsafeFragments {
|
|
if strings.Contains(lower, fragment) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func ValidateRunEndpoint(endpoint domain.RunEndpoint) error {
|
|
var violations []string
|
|
violations = appendRequired(violations, "id", endpoint.ID)
|
|
violations = appendRequired(violations, "displayName", endpoint.DisplayName)
|
|
violations = appendRequired(violations, "version", endpoint.Version)
|
|
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")
|
|
}
|
|
for i, capability := range endpoint.Capabilities {
|
|
if strings.TrimSpace(capability) == "" {
|
|
violations = append(violations, fmt.Sprintf("capabilities[%d] is required", i))
|
|
}
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidateJob(job domain.Job) error {
|
|
var violations []string
|
|
violations = appendRequired(violations, "id", job.ID)
|
|
violations = appendRequired(violations, "runEndpointId", job.RunEndpointID)
|
|
violations = appendRequired(violations, "capability", job.Capability)
|
|
violations = appendRequired(violations, "idempotencyKey", job.IdempotencyKey)
|
|
if !validJobState(job.State) {
|
|
violations = append(violations, "state is invalid")
|
|
}
|
|
if job.Progress.Percent < 0 || job.Progress.Percent > 100 {
|
|
violations = append(violations, "progress.percent must be between 0 and 100")
|
|
}
|
|
if len(job.Progress.Message) > maxProgressMessageLength {
|
|
violations = append(violations, "progress.message is too long")
|
|
}
|
|
if job.TargetKey != "" && !validLogicalFileKey(job.TargetKey) {
|
|
violations = append(violations, "targetKey is not allowed")
|
|
}
|
|
if job.InputRef != "" && !validScopedInputRef(job.InputRef) {
|
|
violations = append(violations, "inputRef is not allowed")
|
|
}
|
|
if job.Capability == domain.JobCapabilityConfigWrite || job.Capability == domain.JobCapabilityFilesRead || job.Capability == domain.JobCapabilityFilesWrite {
|
|
if job.ServerInstanceID == "" {
|
|
violations = append(violations, "serverInstanceId is required for scoped file jobs")
|
|
}
|
|
if job.TargetKey == "" {
|
|
violations = append(violations, "targetKey is required for scoped file jobs")
|
|
}
|
|
}
|
|
if job.Capability == domain.JobCapabilityConfigWrite || job.Capability == domain.JobCapabilityFilesWrite {
|
|
if job.InputRef == "" {
|
|
violations = append(violations, "inputRef is required for scoped write jobs")
|
|
}
|
|
}
|
|
if isRemoteRunCapability(job.Capability) {
|
|
if job.ServerInstanceID == "" {
|
|
violations = append(violations, "serverInstanceId is required for remote access jobs")
|
|
}
|
|
if remoteCapabilityRequiresTargetKey(job.Capability) && job.TargetKey == "" {
|
|
violations = append(violations, "targetKey is required for remote access jobs")
|
|
}
|
|
if remoteCapabilityRequiresInputRef(job.Capability) && job.InputRef == "" {
|
|
violations = append(violations, "inputRef is required for remote access jobs")
|
|
}
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidateArtifact(artifact domain.Artifact) error {
|
|
var violations []string
|
|
violations = appendRequired(violations, "id", artifact.ID)
|
|
violations = appendRequired(violations, "ownerId", artifact.OwnerID)
|
|
violations = appendRequired(violations, "checksum", artifact.Checksum)
|
|
if !validArtifactOwnerKind(artifact.OwnerKind) {
|
|
violations = append(violations, "ownerKind is invalid")
|
|
}
|
|
if !validArtifactState(artifact.State) {
|
|
violations = append(violations, "state is invalid")
|
|
}
|
|
if artifact.SizeBytes < 0 {
|
|
violations = append(violations, "sizeBytes must not be negative")
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidateLogStream(stream domain.LogStream) error {
|
|
var violations []string
|
|
violations = appendRequired(violations, "id", stream.ID)
|
|
violations = appendRequired(violations, "serverInstanceId", stream.ServerInstanceID)
|
|
violations = appendRequired(violations, "streamKey", stream.StreamKey)
|
|
violations = appendRequired(violations, "retentionPolicy", stream.RetentionPolicy)
|
|
if !validLogStreamSource(stream.Source) {
|
|
violations = append(violations, "source is invalid")
|
|
}
|
|
if !validLogStorageBackend(stream.StorageBackend) {
|
|
violations = append(violations, "storageBackend is invalid")
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func ValidateAuditEvent(event domain.AuditEvent) error {
|
|
var violations []string
|
|
violations = appendRequired(violations, "id", event.ID)
|
|
violations = appendRequired(violations, "actorId", event.ActorID)
|
|
violations = appendRequired(violations, "action", event.Action)
|
|
violations = appendRequired(violations, "resourceKind", event.ResourceKind)
|
|
violations = appendRequired(violations, "resourceId", event.ResourceID)
|
|
violations = appendRequired(violations, "summary", event.Summary)
|
|
if !validAuditResult(event.Result) {
|
|
violations = append(violations, "result is invalid")
|
|
}
|
|
if len(event.Summary) > maxAuditSummaryLength {
|
|
violations = append(violations, "summary is too long")
|
|
}
|
|
if looksLikeRawSecret(event.Summary) {
|
|
violations = append(violations, "summary must be redacted")
|
|
}
|
|
return finish(violations)
|
|
}
|
|
|
|
func MissingCapabilities(actual []string, required []string) []string {
|
|
actualSet := make(map[string]struct{}, len(actual))
|
|
for _, capability := range actual {
|
|
actualSet[capability] = struct{}{}
|
|
}
|
|
var missing []string
|
|
for _, capability := range required {
|
|
if _, exists := actualSet[capability]; !exists {
|
|
missing = append(missing, capability)
|
|
}
|
|
}
|
|
return missing
|
|
}
|
|
|
|
type fieldString struct {
|
|
field string
|
|
value string
|
|
}
|
|
|
|
func duplicateViolations(field string, values []string) []string {
|
|
seen := map[string]struct{}{}
|
|
var violations []string
|
|
for i, value := range values {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
violations = append(violations, fmt.Sprintf("%s[%d] is required", field, i))
|
|
continue
|
|
}
|
|
if _, exists := seen[trimmed]; exists {
|
|
violations = append(violations, fmt.Sprintf("%s[%d] duplicates an earlier value", field, i))
|
|
}
|
|
seen[trimmed] = struct{}{}
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func validateDeclaredPluginPermissions(permissions []string) []string {
|
|
var violations []string
|
|
for i, permission := range permissions {
|
|
if !validPluginPermission(permission) {
|
|
violations = append(violations, fmt.Sprintf("permissions[%d] is not allowed", i))
|
|
}
|
|
}
|
|
violations = append(violations, duplicateViolations("permissions", permissions)...)
|
|
return violations
|
|
}
|
|
|
|
func validateLifecycleActions(actions domain.PluginLifecycleActions) []string {
|
|
var violations []string
|
|
required := []fieldString{
|
|
{field: "actions.install", value: actions.Install},
|
|
{field: "actions.start", value: actions.Start},
|
|
{field: "actions.stop", value: actions.Stop},
|
|
}
|
|
for _, action := range required {
|
|
violations = appendRequired(violations, action.field, action.value)
|
|
}
|
|
for _, action := range []fieldString{
|
|
{field: "actions.install", value: actions.Install},
|
|
{field: "actions.start", value: actions.Start},
|
|
{field: "actions.stop", value: actions.Stop},
|
|
{field: "actions.restart", value: actions.Restart},
|
|
{field: "actions.status", value: actions.Status},
|
|
} {
|
|
if strings.TrimSpace(action.value) != "" && !safeRelativeJSONRef(action.value) {
|
|
violations = append(violations, action.field+" must be a safe relative JSON reference")
|
|
}
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func validatePluginPages(pages []domain.GamePluginPage) []string {
|
|
var violations []string
|
|
seenKeys := map[string]struct{}{}
|
|
for i, page := range pages {
|
|
prefix := fmt.Sprintf("pages[%d]", i)
|
|
violations = appendRequired(violations, prefix+".key", page.Key)
|
|
violations = appendRequired(violations, prefix+".title", page.Title)
|
|
violations = appendRequired(violations, prefix+".path", page.Path)
|
|
if len(page.Title) > maxPluginPageTitleLength {
|
|
violations = append(violations, prefix+".title is too long")
|
|
}
|
|
if !validPluginPagePath(page.Path) {
|
|
violations = append(violations, prefix+".path is invalid")
|
|
}
|
|
if page.Key != "" {
|
|
if _, exists := seenKeys[page.Key]; exists {
|
|
violations = append(violations, prefix+".key duplicates another page")
|
|
}
|
|
seenKeys[page.Key] = struct{}{}
|
|
}
|
|
for permissionIndex, permission := range page.Permissions {
|
|
if !validPluginPermission(permission) {
|
|
violations = append(violations, fmt.Sprintf("%s.permissions[%d] is not allowed", prefix, permissionIndex))
|
|
}
|
|
}
|
|
violations = append(violations, duplicateViolations(prefix+".permissions", page.Permissions)...)
|
|
violations = append(violations, validateBridgeActions(prefix+".bridgeActions", page.BridgeActions)...)
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func validateBridgeActions(field string, actions []string) []string {
|
|
var violations []string
|
|
for i, action := range actions {
|
|
if !validPluginBridgeAction(domain.PluginBridgeAction(action)) {
|
|
violations = append(violations, fmt.Sprintf("%s[%d] is not supported", field, i))
|
|
}
|
|
}
|
|
violations = append(violations, duplicateViolations(field, actions)...)
|
|
return violations
|
|
}
|
|
|
|
func validateAIPurposes(purposes []string) []string {
|
|
var violations []string
|
|
for i, purpose := range purposes {
|
|
if !validAIPurpose(purpose) {
|
|
violations = append(violations, fmt.Sprintf("aiPurposes[%d] is not allowed", i))
|
|
}
|
|
}
|
|
violations = append(violations, duplicateViolations("aiPurposes", purposes)...)
|
|
return violations
|
|
}
|
|
|
|
func validateRemoteAccess(field string, remote domain.GamePluginRemoteAccess, declaredCapabilities []string) []string {
|
|
var violations []string
|
|
if len(remote.Methods) == 0 && len(remote.RunCapabilities) == 0 && len(remote.DatabaseEngines) == 0 && !remote.RCON && !remote.LogTransfer {
|
|
return violations
|
|
}
|
|
if len(remote.Methods) == 0 {
|
|
violations = append(violations, field+".methods must not be empty when remote access is declared")
|
|
}
|
|
for i, method := range remote.Methods {
|
|
if !validRemoteAccessMethod(method) {
|
|
violations = append(violations, fmt.Sprintf("%s.methods[%d] is not allowed", field, i))
|
|
}
|
|
}
|
|
violations = append(violations, duplicateViolations(field+".methods", remote.Methods)...)
|
|
for i, capability := range remote.RunCapabilities {
|
|
if !validPluginRunCapability(capability) || !isRemoteRunCapability(capability) {
|
|
violations = append(violations, fmt.Sprintf("%s.runCapabilities[%d] is not allowed", field, i))
|
|
continue
|
|
}
|
|
if !containsString(declaredCapabilities, capability) {
|
|
violations = append(violations, fmt.Sprintf("%s.runCapabilities[%d] must also be declared in capabilities", field, i))
|
|
}
|
|
}
|
|
violations = append(violations, duplicateViolations(field+".runCapabilities", remote.RunCapabilities)...)
|
|
for i, engine := range remote.DatabaseEngines {
|
|
if !validRemoteDatabaseEngine(engine) {
|
|
violations = append(violations, fmt.Sprintf("%s.databaseEngines[%d] is not allowed", field, i))
|
|
}
|
|
}
|
|
violations = append(violations, duplicateViolations(field+".databaseEngines", remote.DatabaseEngines)...)
|
|
if containsString(remote.Methods, "run") && len(remote.RunCapabilities) == 0 {
|
|
violations = append(violations, field+".runCapabilities must not be empty when run access is declared")
|
|
}
|
|
if containsString(remote.Methods, "ftp") && !containsAny(declaredCapabilities, []string{domain.JobCapabilityRemoteFTPRead, domain.JobCapabilityRemoteFTPWrite}) {
|
|
violations = append(violations, field+" requires remote.ftp.read or remote.ftp.write when ftp is declared")
|
|
}
|
|
if containsString(remote.Methods, "rsync") && !containsAny(declaredCapabilities, []string{domain.JobCapabilityRemoteRsyncRead, domain.JobCapabilityRemoteRsyncWrite}) {
|
|
violations = append(violations, field+" requires remote.rsync.read or remote.rsync.write when rsync is declared")
|
|
}
|
|
if remote.RCON && !containsString(remote.RunCapabilities, domain.JobCapabilityRemoteRunRCONCommand) {
|
|
violations = append(violations, field+".rcon requires remote.run.rcon.command")
|
|
}
|
|
if remote.LogTransfer && !containsString(remote.RunCapabilities, domain.JobCapabilityRemoteRunLogsTransfer) {
|
|
violations = append(violations, field+".logTransfer requires remote.run.logs.transfer")
|
|
}
|
|
for _, engine := range remote.DatabaseEngines {
|
|
required := domain.JobCapabilityRemoteRunDBMySQLQuery
|
|
if engine == "sqlite" {
|
|
required = domain.JobCapabilityRemoteRunDBSQLiteQuery
|
|
}
|
|
if !containsString(remote.RunCapabilities, required) {
|
|
violations = append(violations, fmt.Sprintf("%s.databaseEngines requires %s", field, required))
|
|
}
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func validateSafePluginStrings(prefix string, values []fieldString) []string {
|
|
var violations []string
|
|
for _, value := range values {
|
|
for _, reason := range unsafePluginStringReasons(value.value) {
|
|
violations = append(violations, prefix+"."+value.field+": "+reason)
|
|
}
|
|
}
|
|
return violations
|
|
}
|
|
|
|
func pluginSafeStrings(plugin domain.GamePlugin) []fieldString {
|
|
values := []fieldString{
|
|
{field: "id", value: plugin.ID},
|
|
{field: "name", value: plugin.Name},
|
|
{field: "description", value: plugin.Description},
|
|
{field: "serverType", value: plugin.ServerType},
|
|
{field: "serverDisplayName", value: plugin.ServerDisplayName},
|
|
{field: "manifestRef", value: plugin.ManifestRef},
|
|
{field: "createFormSchemaRef", value: plugin.CreateFormSchemaRef},
|
|
{field: "actions.install", value: plugin.LifecycleActions.Install},
|
|
{field: "actions.start", value: plugin.LifecycleActions.Start},
|
|
{field: "actions.stop", value: plugin.LifecycleActions.Stop},
|
|
{field: "actions.restart", value: plugin.LifecycleActions.Restart},
|
|
{field: "actions.status", value: plugin.LifecycleActions.Status},
|
|
}
|
|
values = appendStringSliceFields(values, "supportedOs", plugin.SupportedOS)
|
|
values = appendStringSliceFields(values, "requiredRunCapabilities", plugin.RequiredRunCapabilities)
|
|
values = appendStringSliceFields(values, "declaredPermissions", plugin.DeclaredPermissions)
|
|
values = appendStringSliceFields(values, "tags", plugin.Tags)
|
|
values = appendStringSliceFields(values, "aiPurposes", plugin.AIPurposes)
|
|
values = appendStringSliceFields(values, "bridgeActions", plugin.BridgeActions)
|
|
values = appendStringSliceFields(values, "remoteAccess.methods", plugin.RemoteAccess.Methods)
|
|
values = appendStringSliceFields(values, "remoteAccess.runCapabilities", plugin.RemoteAccess.RunCapabilities)
|
|
values = appendStringSliceFields(values, "remoteAccess.databaseEngines", plugin.RemoteAccess.DatabaseEngines)
|
|
for i, page := range plugin.Pages {
|
|
prefix := fmt.Sprintf("pages[%d]", i)
|
|
values = append(values,
|
|
fieldString{field: prefix + ".key", value: page.Key},
|
|
fieldString{field: prefix + ".title", value: page.Title},
|
|
fieldString{field: prefix + ".path", value: page.Path},
|
|
)
|
|
values = appendStringSliceFields(values, prefix+".permissions", page.Permissions)
|
|
values = appendStringSliceFields(values, prefix+".bridgeActions", page.BridgeActions)
|
|
}
|
|
return values
|
|
}
|
|
|
|
func manifestSafeStrings(registration domain.GamePluginManifestRegistration) []fieldString {
|
|
manifest := registration.Manifest
|
|
values := []fieldString{
|
|
{field: "manifestRef", value: registration.ManifestRef},
|
|
{field: "id", value: manifest.ID},
|
|
{field: "name", value: manifest.Name},
|
|
{field: "description", value: manifest.Description},
|
|
{field: "kind", value: manifest.Kind},
|
|
{field: "server.type", value: manifest.Server.Type},
|
|
{field: "server.displayName", value: manifest.Server.DisplayName},
|
|
{field: "server.createFormSchema", value: manifest.Server.CreateFormSchema},
|
|
{field: "actions.install", value: manifest.Actions.Install},
|
|
{field: "actions.start", value: manifest.Actions.Start},
|
|
{field: "actions.stop", value: manifest.Actions.Stop},
|
|
{field: "actions.restart", value: manifest.Actions.Restart},
|
|
{field: "actions.status", value: manifest.Actions.Status},
|
|
}
|
|
values = appendStringSliceFields(values, "tags", manifest.Tags)
|
|
values = appendStringSliceFields(values, "server.supportedOs", manifest.Server.SupportedOS)
|
|
values = appendStringSliceFields(values, "bridge.actions", manifest.Bridge.Actions)
|
|
values = appendStringSliceFields(values, "capabilities", manifest.Capabilities)
|
|
values = appendStringSliceFields(values, "permissions", manifest.Permissions)
|
|
values = appendStringSliceFields(values, "ai.purposes", manifest.AI.Purposes)
|
|
values = appendStringSliceFields(values, "remoteAccess.methods", manifest.RemoteAccess.Methods)
|
|
values = appendStringSliceFields(values, "remoteAccess.runCapabilities", manifest.RemoteAccess.RunCapabilities)
|
|
values = appendStringSliceFields(values, "remoteAccess.databaseEngines", manifest.RemoteAccess.DatabaseEngines)
|
|
for i, page := range manifest.Pages {
|
|
prefix := fmt.Sprintf("pages[%d]", i)
|
|
values = append(values,
|
|
fieldString{field: prefix + ".key", value: page.Key},
|
|
fieldString{field: prefix + ".title", value: page.Title},
|
|
fieldString{field: prefix + ".path", value: page.Path},
|
|
)
|
|
values = appendStringSliceFields(values, prefix+".permissions", page.Permissions)
|
|
values = appendStringSliceFields(values, prefix+".bridgeActions", page.BridgeActions)
|
|
}
|
|
return values
|
|
}
|
|
|
|
func marketplacePluginSafeStrings(plugin domain.PluginMarketplacePlugin) []fieldString {
|
|
values := []fieldString{
|
|
{field: "id", value: plugin.ID},
|
|
{field: "name", value: plugin.Name},
|
|
{field: "description", value: plugin.Description},
|
|
{field: "serverType", value: plugin.ServerType},
|
|
{field: "serverDisplayName", value: plugin.ServerDisplayName},
|
|
{field: "manifestRef", value: plugin.ManifestRef},
|
|
{field: "createFormSchemaRef", value: plugin.CreateFormSchemaRef},
|
|
{field: "actions.install", value: plugin.LifecycleActions.Install},
|
|
{field: "actions.start", value: plugin.LifecycleActions.Start},
|
|
{field: "actions.stop", value: plugin.LifecycleActions.Stop},
|
|
{field: "actions.restart", value: plugin.LifecycleActions.Restart},
|
|
{field: "actions.status", value: plugin.LifecycleActions.Status},
|
|
{field: "source", value: plugin.Source},
|
|
}
|
|
values = appendStringSliceFields(values, "supportedOs", plugin.SupportedOS)
|
|
values = appendStringSliceFields(values, "capabilities", plugin.Capabilities)
|
|
values = appendStringSliceFields(values, "declaredPermissions", plugin.DeclaredPermissions)
|
|
values = appendStringSliceFields(values, "tags", plugin.Tags)
|
|
values = appendStringSliceFields(values, "aiPurposes", plugin.AIPurposes)
|
|
values = appendStringSliceFields(values, "bridgeActions", plugin.BridgeActions)
|
|
values = appendStringSliceFields(values, "remoteAccess.methods", plugin.RemoteAccess.Methods)
|
|
values = appendStringSliceFields(values, "remoteAccess.runCapabilities", plugin.RemoteAccess.RunCapabilities)
|
|
values = appendStringSliceFields(values, "remoteAccess.databaseEngines", plugin.RemoteAccess.DatabaseEngines)
|
|
for i, page := range plugin.Pages {
|
|
prefix := fmt.Sprintf("pages[%d]", i)
|
|
values = append(values,
|
|
fieldString{field: prefix + ".key", value: page.Key},
|
|
fieldString{field: prefix + ".title", value: page.Title},
|
|
fieldString{field: prefix + ".path", value: page.Path},
|
|
)
|
|
values = appendStringSliceFields(values, prefix+".permissions", page.Permissions)
|
|
values = appendStringSliceFields(values, prefix+".bridgeActions", page.BridgeActions)
|
|
}
|
|
return values
|
|
}
|
|
|
|
func appendStringSliceFields(values []fieldString, field string, items []string) []fieldString {
|
|
for i, item := range items {
|
|
values = append(values, fieldString{field: fmt.Sprintf("%s[%d]", field, i), value: item})
|
|
}
|
|
return values
|
|
}
|
|
|
|
func unsafePluginStringReasons(value string) []string {
|
|
trimmed := strings.TrimSpace(value)
|
|
lowered := strings.ToLower(trimmed)
|
|
if trimmed == "" {
|
|
return nil
|
|
}
|
|
var reasons []string
|
|
if looksLikeRawSecret(trimmed) || strings.Contains(lowered, "raw api key") || strings.Contains(lowered, "provider key") || strings.Contains(lowered, "ai key") || strings.Contains(lowered, "raw credential") {
|
|
reasons = append(reasons, "raw credential or AI/provider key content is not allowed")
|
|
}
|
|
if strings.Contains(lowered, "direct run") || strings.Contains(lowered, "run socket") || strings.Contains(lowered, "run credential") || strings.Contains(lowered, "run token") || strings.Contains(lowered, "direct socket") {
|
|
reasons = append(reasons, "direct run access request is not allowed")
|
|
}
|
|
if strings.HasPrefix(lowered, "file://") || strings.HasPrefix(trimmed, `\\`) || looksLikeRawHostPath(trimmed) || strings.Contains(lowered, "host path") || strings.Contains(lowered, "raw host path") {
|
|
reasons = append(reasons, "raw host path access is not allowed")
|
|
}
|
|
return reasons
|
|
}
|
|
|
|
func looksLikeRawHostPath(value string) bool {
|
|
if len(value) >= 3 && ((value[1] == ':' && value[2] == '\\') || (value[1] == ':' && value[2] == '/')) {
|
|
return true
|
|
}
|
|
lowered := strings.ToLower(value)
|
|
for _, prefix := range []string{"/users/", "/etc/", "/var/", "/tmp/", "/home/", "/root/", "/private/", "/volumes/", "/opt/"} {
|
|
if strings.HasPrefix(lowered, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func safeRef(value string) bool {
|
|
trimmed := strings.TrimSpace(value)
|
|
return strings.HasPrefix(trimmed, "artifact://") || strings.HasPrefix(trimmed, "manifest://") || safeRelativeJSONRef(trimmed)
|
|
}
|
|
|
|
func safeRelativeJSONRef(value string) bool {
|
|
trimmed := strings.TrimSpace(value)
|
|
lowered := strings.ToLower(trimmed)
|
|
if trimmed == "" || strings.HasPrefix(trimmed, "/") || strings.Contains(trimmed, "..") || strings.Contains(trimmed, "://") || strings.Contains(trimmed, `\`) || !strings.HasSuffix(lowered, ".json") {
|
|
return false
|
|
}
|
|
if len(trimmed) >= 2 && trimmed[1] == ':' {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func looksLikeRawSecret(value string) bool {
|
|
trimmed := strings.TrimSpace(strings.ToLower(value))
|
|
if trimmed == "" {
|
|
return false
|
|
}
|
|
if strings.HasPrefix(trimmed, "secret://") || strings.HasPrefix(trimmed, "vault://") || strings.HasPrefix(trimmed, "env://") {
|
|
return false
|
|
}
|
|
return strings.HasPrefix(trimmed, "sk-") ||
|
|
strings.HasPrefix(trimmed, "sk_") ||
|
|
strings.Contains(trimmed, "api_key=") ||
|
|
strings.Contains(trimmed, "apikey=") ||
|
|
strings.Contains(trimmed, "bearer ")
|
|
}
|
|
|
|
func validUserStatus(status domain.UserStatus) bool {
|
|
switch status {
|
|
case domain.UserStatusActive, domain.UserStatusDisabled, domain.UserStatusPending:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validUserRole(role string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(role)) {
|
|
case "admin", "platform-admin", "platformadmin", "server-owner", "owner", "server-admin", "operator":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validAIProviderKind(kind domain.AIProviderKind) bool {
|
|
switch kind {
|
|
case domain.AIProviderKindOpenAICompatible, domain.AIProviderKindOpenAI, domain.AIProviderKindClaude, domain.AIProviderKindGemini, domain.AIProviderKindOllama, domain.AIProviderKindCustom:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validAIRelayMode(mode domain.AIRelayMode) bool {
|
|
switch mode {
|
|
case domain.AIRelayModeDirect, domain.AIRelayModeRelay, domain.AIRelayModeLocal:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validAIProviderStatus(status domain.AIProviderStatus) bool {
|
|
switch status {
|
|
case domain.AIProviderStatusActive, domain.AIProviderStatusDisabled, domain.AIProviderStatusError:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validGamePluginStatus(status domain.GamePluginStatus) bool {
|
|
switch status {
|
|
case domain.GamePluginStatusInstalled, domain.GamePluginStatusDisabled, domain.GamePluginStatusInvalid, domain.GamePluginStatusUpdating:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validPluginMarketplaceStateAction(action domain.PluginMarketplaceStateAction) bool {
|
|
switch action {
|
|
case domain.PluginMarketplaceStateActionInstall, domain.PluginMarketplaceStateActionEnable, domain.PluginMarketplaceStateActionDisable:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validPluginRunCapability(capability string) bool {
|
|
switch capability {
|
|
case "process.install", "process.start", "process.stop", "process.restart", "process.status",
|
|
"config.write",
|
|
"files.list", "files.read", "files.write", "files.patch",
|
|
"file.list", "file.read", "file.write", "file.patch",
|
|
"logs.read", "log.query", domain.JobCapabilityLogsBackfill,
|
|
domain.JobCapabilityRemoteFTPRead, domain.JobCapabilityRemoteFTPWrite,
|
|
domain.JobCapabilityRemoteRsyncRead, domain.JobCapabilityRemoteRsyncWrite,
|
|
domain.JobCapabilityRemoteRunFilesRead, domain.JobCapabilityRemoteRunFilesWrite,
|
|
domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop,
|
|
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
|
domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand,
|
|
domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall,
|
|
"artifacts.read", "artifacts.write", "artifact.read", "artifact.write",
|
|
"ai.invoke":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isRemoteRunCapability(capability string) bool {
|
|
return strings.HasPrefix(capability, "remote.")
|
|
}
|
|
|
|
func remoteCapabilityRequiresTargetKey(capability string) bool {
|
|
switch capability {
|
|
case domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop:
|
|
return false
|
|
default:
|
|
return isRemoteRunCapability(capability)
|
|
}
|
|
}
|
|
|
|
func remoteCapabilityRequiresInputRef(capability string) bool {
|
|
switch capability {
|
|
case domain.JobCapabilityRemoteFTPWrite,
|
|
domain.JobCapabilityRemoteRsyncWrite,
|
|
domain.JobCapabilityRemoteRunFilesWrite,
|
|
domain.JobCapabilityRemoteRunDBMySQLQuery,
|
|
domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
|
domain.JobCapabilityRemoteRunRCONCommand:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validRemoteAccessMethod(method string) bool {
|
|
switch method {
|
|
case "ftp", "rsync", "run":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validRemoteDatabaseEngine(engine string) bool {
|
|
switch engine {
|
|
case "mysql", "sqlite":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validFileOperationKind(operation domain.FileOperationKind) bool {
|
|
switch operation {
|
|
case domain.FileOperationRead, domain.FileOperationWrite:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validConfigFileKey(key string) bool {
|
|
switch key {
|
|
case "server.properties", "config/server.properties":
|
|
return true
|
|
default:
|
|
return strings.HasPrefix(key, "config/") && (strings.HasSuffix(key, ".properties") || strings.HasSuffix(key, ".json"))
|
|
}
|
|
}
|
|
|
|
func validLogicalFileKey(key string) bool {
|
|
trimmed := strings.TrimSpace(key)
|
|
if trimmed == "" || trimmed != key || len([]rune(key)) > maxLogicalFileKeyLength {
|
|
return false
|
|
}
|
|
if strings.HasPrefix(key, "/") || strings.Contains(key, "..") || strings.Contains(key, `\`) || strings.Contains(key, "://") || looksLikeRawHostPath(key) || containsUnsafeRuntimeSecret(key) {
|
|
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)
|
|
if trimmed == "" || trimmed != ref || containsUnsafeRuntimeSecret(ref) || looksLikeRawHostPath(ref) {
|
|
return false
|
|
}
|
|
return strings.HasPrefix(ref, "input://") || strings.HasPrefix(ref, "artifact://")
|
|
}
|
|
|
|
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":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validPluginBridgeAction(action domain.PluginBridgeAction) bool {
|
|
switch action {
|
|
case domain.PluginBridgeActionServerInstancesRead,
|
|
domain.PluginBridgeActionJobsDispatch,
|
|
domain.PluginBridgeActionLogsQuery,
|
|
domain.PluginBridgeActionArtifactsOpen,
|
|
domain.PluginBridgeActionFilesRequest,
|
|
domain.PluginBridgeActionRemoteAccessRequest,
|
|
domain.PluginBridgeActionRunDistribution,
|
|
domain.PluginBridgeActionDependenciesRequest,
|
|
domain.PluginBridgeActionLogsBackfillRequest,
|
|
domain.PluginBridgeActionClientManager,
|
|
domain.PluginBridgeActionAIInvoke:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func requiredBridgePermissions(action domain.PluginBridgeAction) []string {
|
|
switch action {
|
|
case domain.PluginBridgeActionServerInstancesRead:
|
|
return []string{"server.read"}
|
|
case domain.PluginBridgeActionJobsDispatch:
|
|
return []string{"server.lifecycle"}
|
|
case domain.PluginBridgeActionLogsQuery:
|
|
return []string{"server.logs.read"}
|
|
case domain.PluginBridgeActionArtifactsOpen:
|
|
return []string{"server.artifacts.read"}
|
|
case domain.PluginBridgeActionFilesRequest:
|
|
return []string{"server.files.read"}
|
|
case domain.PluginBridgeActionRemoteAccessRequest:
|
|
return []string{"server.remote.access"}
|
|
case domain.PluginBridgeActionRunDistribution:
|
|
return []string{"server.run.distribution"}
|
|
case domain.PluginBridgeActionDependenciesRequest:
|
|
return []string{"server.dependencies.manage"}
|
|
case domain.PluginBridgeActionLogsBackfillRequest:
|
|
return []string{"server.logs.read"}
|
|
case domain.PluginBridgeActionClientManager:
|
|
return []string{"server.client-manager.manage"}
|
|
case domain.PluginBridgeActionAIInvoke:
|
|
return []string{"ai.invoke"}
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func effectivePagePermissions(plugin domain.GamePlugin, routeKey string) []string {
|
|
declared := plugin.DeclaredPermissions
|
|
page, found := findPluginPage(plugin.Pages, routeKey)
|
|
if !found || len(page.Permissions) == 0 {
|
|
return domain.CopyStringSlice(declared)
|
|
}
|
|
var effective []string
|
|
for _, permission := range page.Permissions {
|
|
if containsString(declared, permission) {
|
|
effective = append(effective, permission)
|
|
}
|
|
}
|
|
return effective
|
|
}
|
|
|
|
func findPluginPage(pages []domain.GamePluginPage, routeKey string) (domain.GamePluginPage, bool) {
|
|
for _, page := range pages {
|
|
if page.Key == routeKey {
|
|
return page, true
|
|
}
|
|
}
|
|
return domain.GamePluginPage{}, false
|
|
}
|
|
|
|
func containsAll(values []string, required []string) bool {
|
|
for _, value := range required {
|
|
if !containsString(values, value) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func containsAny(values []string, candidates []string) bool {
|
|
for _, candidate := range candidates {
|
|
if containsString(values, candidate) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func containsString(values []string, target string) bool {
|
|
for _, value := range values {
|
|
if value == target {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func validPluginSupportedOS(osName string) bool {
|
|
switch osName {
|
|
case "windows", "linux", "darwin":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validPluginPagePath(path string) bool {
|
|
if !strings.HasPrefix(path, "/") || strings.Contains(path, "..") || strings.Contains(path, `\`) || strings.Contains(path, "://") {
|
|
return false
|
|
}
|
|
for _, char := range path[1:] {
|
|
if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '_' || char == '.' || char == '/' || char == '-' {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validAIPurpose(purpose string) bool {
|
|
switch purpose {
|
|
case "config.read", "config.generate", "config.suggest", "logs.diagnose", "files.suggest":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validServerInstanceState(state domain.ServerInstanceState) bool {
|
|
switch state {
|
|
case domain.ServerInstanceStateDraft, domain.ServerInstanceStateInstalling, domain.ServerInstanceStateReady, domain.ServerInstanceStateRunning, domain.ServerInstanceStateStopped, domain.ServerInstanceStateFailed, domain.ServerInstanceStateDeleted:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validRunEndpointStatus(status domain.RunEndpointStatus) bool {
|
|
switch status {
|
|
case domain.RunEndpointStatusOnline, domain.RunEndpointStatusOffline, domain.RunEndpointStatusDegraded, domain.RunEndpointStatusDisabled:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validJobState(state domain.JobState) bool {
|
|
switch state {
|
|
case domain.JobStateQueued, domain.JobStateAccepted, domain.JobStateRunning, domain.JobStateSucceeded, domain.JobStateFailed, domain.JobStateCancelled:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validArtifactOwnerKind(kind domain.ArtifactOwnerKind) bool {
|
|
switch kind {
|
|
case domain.ArtifactOwnerKindPlatform, domain.ArtifactOwnerKindPlugin, domain.ArtifactOwnerKindServerInstance, domain.ArtifactOwnerKindJob:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validArtifactState(state domain.ArtifactState) bool {
|
|
switch state {
|
|
case domain.ArtifactStateUploading, domain.ArtifactStateAvailable, domain.ArtifactStateExpired, domain.ArtifactStateFailed:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validLogStreamSource(source domain.LogStreamSource) bool {
|
|
switch source {
|
|
case domain.LogStreamSourceProcess, domain.LogStreamSourceFile, domain.LogStreamSourcePlugin:
|
|
return true
|
|
default:
|
|
return strings.TrimSpace(string(source)) != ""
|
|
}
|
|
}
|
|
|
|
func validLogStorageBackend(backend domain.LogStorageBackend) bool {
|
|
switch backend {
|
|
case domain.LogStorageBackendLocalSegments, domain.LogStorageBackendLoki, domain.LogStorageBackendClickHouse, domain.LogStorageBackendOpenSearch, domain.LogStorageBackendElasticsearch:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func validAuditResult(result domain.AuditResult) bool {
|
|
switch result {
|
|
case domain.AuditResultSuccess, domain.AuditResultDenied, domain.AuditResultFailed, domain.AuditResultQueued:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|