Tighten opaque plugin content boundaries

This commit is contained in:
npc0-hue
2026-09-03 18:24:39 +08:00
parent 80cddbf19d
commit 14cbc63e61
31 changed files with 452 additions and 558 deletions
-135
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"math"
"regexp"
"strings"
"unicode"
"unicode/utf8"
@@ -23,12 +22,6 @@ const (
maxGameClientBridgeSessionLength = 4096
)
var (
gameClientBridgeAcronymBoundary = regexp.MustCompile(`([A-Z]+)([A-Z][a-z])`)
gameClientBridgeCamelBoundary = regexp.MustCompile(`([a-z0-9])([A-Z])`)
gameClientBridgeNonWord = regexp.MustCompile(`[^A-Za-z0-9]+`)
)
func ValidateGameClientBridgeQueueRequest(request domain.GameClientBridgeQueueRequest) error {
var violations []string
violations = appendGameClientBridgeIdentifier(violations, "serverInstanceId", request.ServerInstanceID, true)
@@ -185,13 +178,6 @@ func appendGameClientBridgeText(violations []string, field, value string, maximu
if !utf8.ValidString(value) || strings.TrimSpace(value) != value || utf8.RuneCountInString(value) > maximum || containsControlCharacter(value) {
violations = append(violations, field+" is invalid")
}
lowered := strings.ToLower(strings.TrimSpace(value))
if containsUnsafeRuntimeSecret(value) || looksLikeRawHostPath(value) || hasUnsafeGameClientBridgeReference(lowered) || containsEmbeddedGameClientBridgeHostPath(lowered) {
violations = append(violations, field+" contains unsafe connection or host material")
}
for _, reason := range unsafePluginStringReasons(value) {
violations = append(violations, field+": "+reason)
}
return violations
}
@@ -268,10 +254,6 @@ func validateGameClientBridgePayloadValue(field string, value any, depth int, bu
violations = append(violations, field+" key "+fmt.Sprintf("%q", key)+" is invalid")
continue
}
if unsafeGameClientBridgePayloadKey(key) {
violations = append(violations, field+" contains forbidden key "+key)
continue
}
violations = append(violations, validateGameClientBridgePayloadValue(field+"."+key, item, depth+1, budget)...)
}
return violations
@@ -300,13 +282,6 @@ func validateGameClientBridgePayloadString(field, value string) []string {
if containsControlCharacter(value) {
violations = append(violations, field+" contains control characters")
}
for _, reason := range unsafePluginStringReasons(value) {
violations = append(violations, field+": "+reason)
}
lowered := strings.ToLower(strings.TrimSpace(value))
if containsUnsafeRuntimeSecret(value) || hasUnsafeGameClientBridgeReference(lowered) || containsEmbeddedGameClientBridgeHostPath(lowered) {
violations = append(violations, field+" contains unsafe connection material")
}
return violations
}
@@ -321,113 +296,3 @@ func validGameClientBridgePayloadKey(key string) bool {
}
return true
}
func unsafeGameClientBridgePayloadKey(key string) bool {
tokens := gameClientBridgePayloadKeyTokens(key)
if len(tokens) == 0 {
return true
}
normalized := strings.Join(tokens, "")
for _, exact := range []string{
"absolutepath", "apikey", "commandline", "componentkey", "credential", "credentials", "directsocket", "dsn", "hostpath", "password", "passwd", "rawpath", "rawsql", "runendpoint", "runsocket", "script", "secret", "sessiontoken", "shell", "socket", "sql", "statement", "terminalcommand",
} {
if normalized == strings.ReplaceAll(exact, " ", "") {
return true
}
}
if last := tokens[len(tokens)-1]; last == "password" || last == "passwd" || last == "secret" || last == "credential" || last == "credentials" || last == "dsn" {
return true
}
for _, sequence := range [][]string{
{"api", "key"},
{"access", "key"},
{"private", "key"},
{"auth", "token"},
{"access", "token"},
{"client", "secret"},
{"storage", "credential"},
{"component", "key"},
{"session", "token"},
{"host", "path"},
{"raw", "path"},
{"absolute", "path"},
{"file", "system", "path"},
{"direct", "socket"},
{"socket", "path"},
{"socket", "address"},
{"socket", "url"},
{"socket", "endpoint"},
{"run", "endpoint"},
{"run", "url"},
{"run", "socket"},
{"run", "token"},
{"run", "credential"},
{"raw", "sql"},
{"raw", "query"},
{"sql", "text"},
{"sql", "query"},
{"sql", "statement"},
{"arbitrary", "sql"},
{"shell", "command"},
{"shell", "script"},
{"script", "body"},
{"terminal", "command"},
{"command", "line"},
{"arbitrary", "shell"},
} {
if gameClientBridgeContainsSensitiveSequence(tokens, sequence) {
return true
}
}
return false
}
func containsEmbeddedGameClientBridgeHostPath(value string) bool {
for _, marker := range []string{"/etc/", "/var/", "/tmp/", "/home/", "/root/", "/private/", "/users/", "/volumes/", "/opt/", `:\\`} {
if strings.Contains(value, marker) {
return true
}
}
return false
}
func gameClientBridgeContainsSensitiveSequence(tokens, sequence []string) bool {
for start := 0; start+len(sequence) <= len(tokens); start++ {
matched := true
for index, expected := range sequence {
if tokens[start+index] != expected {
matched = false
break
}
}
if !matched {
continue
}
end := start + len(sequence)
if end == len(tokens) {
return true
}
switch tokens[end] {
case "address", "body", "content", "material", "path", "raw", "ref", "text", "url", "value":
return true
}
}
return false
}
func gameClientBridgePayloadKeyTokens(key string) []string {
withAcronymBoundaries := gameClientBridgeAcronymBoundary.ReplaceAllString(key, `${1} ${2}`)
withCamelBoundaries := gameClientBridgeCamelBoundary.ReplaceAllString(withAcronymBoundaries, `${1} ${2}`)
return strings.Fields(strings.ToLower(gameClientBridgeNonWord.ReplaceAllString(withCamelBoundaries, " ")))
}
func hasUnsafeGameClientBridgeReference(value string) bool {
for _, fragment := range []string{
"unix://", "tcp://", "mysql://", "postgres://", "postgresql://", "mongodb://", "redis://", "sqlite://", "sqlserver://", "mssql://", "odbc:", "secret://", "vault://", "env://", "http://127.", "https://127.", "http://localhost", "https://localhost",
} {
if strings.Contains(value, fragment) {
return true
}
}
return false
}
@@ -1,33 +0,0 @@
package validator
import (
"strings"
"testing"
"browser.local/platform/domain"
)
func TestValidateGameClientBridgeCompanionDeclarationIsUnsupported(t *testing.T) {
bridge := domain.GameClientBridgeManifest{
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000},
Companion: domain.GameClientBridgeCompanionDeclaration{
ProfileKey: "scum-client-manager",
ConfigTemplateKey: "client-config",
ConfigSchemaRef: "schemas/companion/config.schema.json",
ConfigFormat: "yaml",
PlatformBaseURLSource: "run-control",
RegistrationProof: "hmac-sha256",
ProofMaterialSource: "component-package",
ProofMaterialEnv: "SCUM_COMPONENT_PROOF",
SessionMode: "component-session",
TLSPolicy: "verify-system-roots",
HeartbeatIntervalSeconds: 30,
CommandPollIntervalSeconds: 5,
RequestTimeoutSeconds: 15,
},
}
violations := validateGameClientBridgeManifest("gameClientBridge", bridge, nil, nil, nil, domain.GamePluginRuntimeProfiles{})
if !strings.Contains(strings.Join(violations, "; "), "gameClientBridge.companion is no longer supported") {
t.Fatalf("expected companion unsupported violation, got %v", violations)
}
}
+15 -9
View File
@@ -53,8 +53,6 @@ func TestValidateGameClientBridgeRequestFieldBounds(t *testing.T) {
{name: "claim limit", err: ValidateGameClientBridgeClaimRequest(domain.GameClientBridgeClaimRequest{SessionToken: "session", Limit: 51}), want: "limit"},
{name: "ack fence", err: ValidateGameClientBridgeAckRequest(domain.GameClientBridgeAckRequest{SessionToken: "session", CommandID: "command-1"}), want: "fencingToken"},
{name: "result state", err: ValidateGameClientBridgeResultRequest(domain.GameClientBridgeResultRequest{SessionToken: "session", CommandID: "command-1", FencingToken: 1, Status: "unexpected"}), want: "status"},
{name: "result text", err: ValidateGameClientBridgeResultRequest(domain.GameClientBridgeResultRequest{SessionToken: "session", CommandID: "command-1", FencingToken: 1, Status: domain.GameClientBridgeResultFailed, Summary: "read /etc/passwd"}), want: "unsafe"},
{name: "cancel text", err: ValidateGameClientBridgeCancelRequest(domain.GameClientBridgeCancelRequest{CommandID: "command-1", Reason: "Bearer private"}), want: "unsafe"},
{name: "snapshot payload", err: ValidateGameClientBridgeSnapshotIngestRequest(domain.GameClientBridgeSnapshotIngestRequest{SessionToken: "session", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: time.Now(), Retention: domain.GameClientBridgeRetention{KeepForSeconds: 1}}), want: "payload"},
{name: "snapshot sequence", err: ValidateGameClientBridgeSnapshotIngestRequest(domain.GameClientBridgeSnapshotIngestRequest{SessionToken: "session", Type: "players", SchemaVersion: "1", StreamKey: "current", ObservedAt: time.Now(), Payload: map[string]any{}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 1}}), want: "sequence"},
{name: "snapshot retention", err: ValidateGameClientBridgeSnapshotIngestRequest(domain.GameClientBridgeSnapshotIngestRequest{SessionToken: "session", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: time.Now(), Payload: map[string]any{}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 31*24*60*60 + 1}}), want: "keepForSeconds"},
@@ -81,32 +79,40 @@ func TestValidateGameClientBridgePayloadAcceptsJSONValuesWithoutKeyFalsePositive
}
}
func TestValidateGameClientBridgePayloadRejectsUnsafeKeyPatterns(t *testing.T) {
func TestValidateGameClientBridgePayloadPreservesPluginOwnedKeyNames(t *testing.T) {
keys := []string{"sessionToken", "authToken", "accessKey", "privateKey", "component-key", "databasePassword", "clientSecret", "api_key", "databaseDSN", "storageCredential", "hostPath", "absolute_path", "directSocket", "socketAddress", "runEndpoint", "runUrl", "rawSQL", "rawQuery", "sqlText", "sql_statement", "shellCommand", "shell_script", "scriptBody", "terminalCommand", "commandLine"}
for _, key := range keys {
t.Run(key, func(t *testing.T) {
request := validBridgeQueueRequest()
request.Payload = map[string]any{key: "value"}
err := ValidateGameClientBridgeQueueRequest(request)
if err == nil || !strings.Contains(err.Error(), "forbidden key") {
t.Fatalf("expected forbidden key rejection, got %v", err)
if err := ValidateGameClientBridgeQueueRequest(request); err != nil {
t.Fatalf("plugin-owned key %q should pass structural validation: %v", key, err)
}
})
}
}
func TestValidateGameClientBridgePayloadRejectsUnsafeStringMaterial(t *testing.T) {
func TestValidateGameClientBridgePayloadPreservesOpaqueStringMaterial(t *testing.T) {
values := []string{"/etc/passwd", "prefix path=/var/run/run.sock", `C:\\Users\\operator\\secret.txt`, "tcp://127.0.0.1:9000", "unix:///var/run/run.sock", "http://localhost:9000", "mysql://user:password@host/db", "secret://component/key", "vault://runtime/token", "Bearer abc123", "password=leak"}
for index, value := range values {
request := validBridgeQueueRequest()
request.IdempotencyKey = fmt.Sprintf("case-%d", index)
request.Payload = map[string]any{"value": value}
if err := ValidateGameClientBridgeQueueRequest(request); err == nil || !strings.Contains(err.Error(), "payload") {
t.Fatalf("expected unsafe value %q rejection, got %v", value, err)
if err := ValidateGameClientBridgeQueueRequest(request); err != nil {
t.Fatalf("opaque plugin value %q should pass structural validation: %v", value, err)
}
}
}
func TestValidateGameClientBridgeResultAndCancelTextPreserveOpaqueContent(t *testing.T) {
if err := ValidateGameClientBridgeResultRequest(domain.GameClientBridgeResultRequest{SessionToken: "session", CommandID: "command-1", FencingToken: 1, Status: domain.GameClientBridgeResultFailed, Summary: "read /etc/passwd password=opaque"}); err != nil {
t.Fatalf("opaque result summary should pass structural validation: %v", err)
}
if err := ValidateGameClientBridgeCancelRequest(domain.GameClientBridgeCancelRequest{CommandID: "command-1", Reason: "Bearer private game token text"}); err != nil {
t.Fatalf("opaque cancel reason should pass structural validation: %v", err)
}
}
func TestValidateGameClientBridgePayloadRejectsNonJSONValuesAndInvalidNumbers(t *testing.T) {
tests := map[string]any{"typed map": map[string]string{"key": "value"}, "typed slice": []string{"value"}, "time": time.Now(), "channel": make(chan int), "not a number": math.NaN(), "infinity": math.Inf(1), "invalid number": json.Number("01")}
for name, value := range tests {
+2 -12
View File
@@ -109,8 +109,8 @@ func validateRemoteAdapterInputs(field string, inputs map[string]string) []strin
}
var violations []string
for key, value := range inputs {
if !runtimeIdentifierPattern.MatchString(key) || unsafeRemoteAdapterInputKey(key) {
violations = append(violations, field+" key is invalid or unsafe")
if !runtimeIdentifierPattern.MatchString(key) {
violations = append(violations, field+" key is invalid")
}
limit := 2048
if remoteAdapterSQLInputKey(key) {
@@ -119,20 +119,10 @@ func validateRemoteAdapterInputs(field string, inputs map[string]string) []strin
if len([]rune(value)) > limit {
violations = append(violations, field+"."+key+" is too long")
}
for _, reason := range unsafePluginStringReasons(value) {
violations = append(violations, field+"."+key+": "+reason)
}
}
return violations
}
func unsafeRemoteAdapterInputKey(key string) bool {
if remoteAdapterSQLInputKey(key) {
return false
}
return unsafeGameClientBridgePayloadKey(key)
}
func remoteAdapterSQLInputKey(key string) bool {
normalized := strings.ToLower(strings.NewReplacer(".", "", "_", "", "-", "", ":", "", "/", "").Replace(key))
switch normalized {
+3
View File
@@ -21,6 +21,9 @@ func TestObservabilityValidatorsBoundMetricsBackupsAndRemoteTargets(t *testing.T
if err := ValidateRemoteAdapterRequest(domain.RemoteAdapterRequest{ServerInstanceID: "server-1", DeclarationKey: "sqlite-db", TargetKey: "scum-db", Capability: domain.JobCapabilityRemoteRunDBSQLiteExecute, IdempotencyKey: "sql-execute-1", Inputs: map[string]string{"mode": "execute", "sqlText": "UPDATE prisoner SET stamina = 855 WHERE id = 'steam-123';"}}); err != nil {
t.Fatalf("expected SQL text input to validate: %v", err)
}
if err := ValidateRemoteAdapterRequest(domain.RemoteAdapterRequest{ServerInstanceID: "server-1", DeclarationKey: "rcon", TargetKey: "scum-rcon", Capability: domain.JobCapabilityRemoteRunRCONCommand, IdempotencyKey: "rcon-command-1", Inputs: map[string]string{"command": "#Login password=opaque /Users/operator note tcp://127.0.0.1:7777"}}); err != nil {
t.Fatalf("expected opaque RCON input to validate: %v", err)
}
}
func floatPtr(value float64) *float64 { return &value }
+11 -34
View File
@@ -33,6 +33,9 @@ var (
gameClientBridgeCollectionPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9._-]{0,119}$`)
gameClientBridgeFieldPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9._-]{0,79}$`)
gameClientBridgeCaptureNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{0,79}$`)
gameClientBridgeAcronymBoundary = regexp.MustCompile(`([A-Z]+)([A-Z][a-z])`)
gameClientBridgeCamelBoundary = regexp.MustCompile(`([a-z0-9])([A-Z])`)
gameClientBridgeNonWord = regexp.MustCompile(`[^A-Za-z0-9]+`)
runtimeIdentifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$`)
)
@@ -467,8 +470,7 @@ func ValidatePluginCreateInputs(fields []domain.PluginCreateField, inputs map[st
}
func validateGameClientBridgeManifest(field string, bridge domain.GameClientBridgeManifest, permissions []string, runCapabilities []string, pages []domain.GamePluginPage, runtimeProfiles domain.GamePluginRuntimeProfiles) []string {
companionPresent := bridge.Companion != (domain.GameClientBridgeCompanionDeclaration{})
if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.LifecycleProjections) == 0 && len(bridge.DataPacks) == 0 && len(bridge.Pages) == 0 && len(bridge.Features) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent {
if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.LifecycleProjections) == 0 && len(bridge.DataPacks) == 0 && len(bridge.Pages) == 0 && len(bridge.Features) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 {
return nil
}
var violations []string
@@ -478,9 +480,6 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
if bridge.Retention.MaxRecords <= 0 || bridge.Retention.MaxRecords > 100000 {
violations = append(violations, field+".maxCommands is invalid")
}
if companionPresent {
violations = append(violations, field+".companion is no longer supported")
}
transports := map[string]domain.RuntimeTransportProfile{}
for _, transport := range runtimeProfiles.TransportProfiles {
transports[transport.Key] = transport
@@ -880,27 +879,8 @@ func validateGameClientBridgeBulkActivityTarget(prefix string, target domain.Gam
return violations
}
func validCompanionProofEnvironment(value string) bool {
if len(value) < 3 || len(value) > 64 || value[0] < 'A' || value[0] > 'Z' {
return false
}
for _, character := range value[1:] {
if character != '_' && (character < 'A' || character > 'Z') && (character < '0' || character > '9') {
return false
}
}
reserved := map[string]struct{}{
"COMSPEC": {}, "DYLD_INSERT_LIBRARIES": {}, "DYLD_LIBRARY_PATH": {}, "HOME": {}, "LD_LIBRARY_PATH": {}, "LD_PRELOAD": {},
"PATH": {}, "PATHEXT": {}, "SHELL": {}, "SYSTEMROOT": {}, "TEMP": {}, "TMP": {}, "USERPROFILE": {}, "WINDIR": {},
}
if _, exists := reserved[value]; exists {
return false
}
return true
}
func unsafeGameClientBridgeCommandType(value string) bool {
tokens := gameClientBridgePayloadKeyTokens(value)
tokens := gameClientBridgeCommandTypeTokens(value)
tokenSet := make(map[string]struct{}, len(tokens))
for _, token := range tokens {
tokenSet[token] = struct{}{}
@@ -920,6 +900,12 @@ func unsafeGameClientBridgeCommandType(value string) bool {
return has("shell", "powershell", "script", "terminal", "execute", "exec", "eval") || has("command", "cmd", "process", "system", "os", "executor") && has("run")
}
func gameClientBridgeCommandTypeTokens(value string) []string {
withAcronymBoundaries := gameClientBridgeAcronymBoundary.ReplaceAllString(value, `${1} ${2}`)
withCamelBoundaries := gameClientBridgeCamelBoundary.ReplaceAllString(withAcronymBoundaries, `${1} ${2}`)
return strings.Fields(strings.ToLower(gameClientBridgeNonWord.ReplaceAllString(withCamelBoundaries, " ")))
}
func ValidatePluginBridgeAuthorizeRequest(request domain.PluginBridgeAuthorizeRequest) error {
var violations []string
violations = appendRequired(violations, "pluginId", request.PluginID)
@@ -972,15 +958,6 @@ func ValidatePluginBridgeExecuteRequest(request domain.PluginBridgeExecuteReques
if len([]rune(value)) > valueLimit {
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")
+2 -2
View File
@@ -2,7 +2,7 @@
- API handlers must use named DTOs from `platform/dto`.
- Platform services must not accept raw plugin-provided host paths.
- AI provider secrets must be stored by reference and redacted from logs and plugin bridge responses.
- AI provider secrets must be stored by reference and hidden from platform diagnostics and plugin bridge responses.
- Game management plugin installation must validate manifest identity, server type, required run capabilities, pages, permissions, and schema references.
- Server instance creation must validate plugin installation state and run endpoint capability compatibility.
- `platform/validator/resources.go` validates required IDs, enum values, AI key-reference shape, bounded progress summaries, artifact metadata, log stream cursors, and run capability compatibility.
@@ -10,4 +10,4 @@
- Job creation must require an idempotency key and return the existing job for duplicate `(runEndpointId, idempotencyKey)` pairs.
# Runtime and bridge validation
Runtime validation rejects undeclared operations, stale attempt/key generations, cross-owner/server/target artifacts, unavailable endpoints, raw secrets, endpoint/socket values, traversal or absolute executable references, shell metacharacters, and unbounded timeouts. Game-client bridge validation rejects legacy companion declarations and keeps operator-facing DTOs redacted before they cross the Platform boundary.
Runtime validation rejects undeclared operations, stale attempt/key generations, cross-owner/server/target artifacts, unavailable endpoints, raw secrets, endpoint/socket values, traversal or absolute executable references, shell metacharacters, and unbounded timeouts. Game-client bridge validation keeps operator-facing transport metadata bounded before it crosses the Platform boundary; plugin-owned request text, result payloads, records, and log bodies remain opaque.