Quellcode durchsuchen

fix(xray): confine log paths written under any key case

resolveXrayLogPaths looked the log object up by the exact keys "access" and
"error", but xray-core decodes that object with encoding/json, which falls back
to a case-insensitive field match. "Access": "/tmp/pwn.log" therefore reached
AccessLog untouched and Xray — root, in a standard install — created the file
there, reopening the arbitrary write that GHSA-jm48-m3rr-9hgg closed.

Fold every case variant onto the canonical key before confining it. When both a
canonical key and a variant are present the canonical value wins, so a
"none" cannot be overridden by a smuggled "Access" path.
Sanaei vor 7 Stunden
Ursprung
Commit
f9de0226fe
2 geänderte Dateien mit 105 neuen und 1 gelöschten Zeilen
  1. 32 1
      internal/web/service/xray.go
  2. 73 0
      internal/web/service/xray_log_confine_test.go

+ 32 - 1
internal/web/service/xray.go

@@ -7,6 +7,7 @@ import (
 	"path"
 	"path/filepath"
 	"runtime"
+	"slices"
 	"strings"
 	"sync"
 
@@ -1034,6 +1035,19 @@ func ensureStatsPolicy(policy json_util.RawMessage) json_util.RawMessage {
 	return out
 }
 
+// caseVariantKeys returns every key of parsed that equals want ignoring case,
+// lowest first so the fold is deterministic when several variants are present.
+func caseVariantKeys(parsed map[string]any, want string) []string {
+	var keys []string
+	for key := range parsed {
+		if strings.EqualFold(key, want) {
+			keys = append(keys, key)
+		}
+	}
+	slices.Sort(keys)
+	return keys
+}
+
 func resolveXrayLogPaths(logCfg json_util.RawMessage) json_util.RawMessage {
 	if len(logCfg) == 0 {
 		return logCfg
@@ -1044,12 +1058,29 @@ func resolveXrayLogPaths(logCfg json_util.RawMessage) json_util.RawMessage {
 	}
 	changed := false
 	for _, key := range []string{"access", "error"} {
-		v, ok := parsed[key].(string)
+		// xray-core decodes this object with encoding/json, whose case-insensitive
+		// field match makes "Access" reach AccessLog too — fold every variant.
+		variants := caseVariantKeys(parsed, key)
+		value, hasValue := parsed[key]
+		for _, variant := range variants {
+			if variant == key {
+				continue
+			}
+			if !hasValue {
+				value, hasValue = parsed[variant], true
+			}
+			delete(parsed, variant)
+			changed = true
+		}
+		v, ok := value.(string)
 		if !ok {
 			continue
 		}
 		trimmed := strings.TrimSpace(v)
 		if trimmed == "" || strings.EqualFold(trimmed, "none") {
+			if changed {
+				parsed[key] = v
+			}
 			continue
 		}
 		base := path.Base(filepath.ToSlash(trimmed))

+ 73 - 0
internal/web/service/xray_log_confine_test.go

@@ -0,0 +1,73 @@
+package service
+
+import (
+	"encoding/json"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
+)
+
+// A log path must never escape the log folder whatever case the key is written
+// in: xray-core matches JSON keys onto its struct fields case-insensitively.
+func TestResolveXrayLogPathsConfinesEveryKeyCase(t *testing.T) {
+	folder := config.GetLogFolder()
+	tests := []struct {
+		name string
+		in   string
+		want map[string]any
+	}{
+		{
+			name: "lowercase keys",
+			in:   `{"access":"/tmp/pwn.log","error":"/tmp/pwn-err.log"}`,
+			want: map[string]any{"access": filepath.Join(folder, "pwn.log"), "error": filepath.Join(folder, "pwn-err.log")},
+		},
+		{
+			name: "capitalised keys",
+			in:   `{"Access":"/tmp/pwn.log","Error":"/tmp/pwn-err.log"}`,
+			want: map[string]any{"access": filepath.Join(folder, "pwn.log"), "error": filepath.Join(folder, "pwn-err.log")},
+		},
+		{
+			name: "upper-case keys",
+			in:   `{"ACCESS":"/tmp/pwn.log"}`,
+			want: map[string]any{"access": filepath.Join(folder, "pwn.log")},
+		},
+		{
+			name: "a variant cannot smuggle a path past a none",
+			in:   `{"access":"none","Access":"/tmp/pwn.log"}`,
+			want: map[string]any{"access": "none"},
+		},
+		{
+			name: "already confined name is left alone",
+			in:   `{"access":"none","error":"none","loglevel":"warning"}`,
+			want: map[string]any{"access": "none", "error": "none", "loglevel": "warning"},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			out := resolveXrayLogPaths(json_util.RawMessage(tt.in))
+			var got map[string]any
+			if err := json.Unmarshal(out, &got); err != nil {
+				t.Fatalf("unmarshal %s: %v", out, err)
+			}
+			if len(got) != len(tt.want) {
+				t.Fatalf("got %v, want %v", got, tt.want)
+			}
+			for key, want := range tt.want {
+				if got[key] != want {
+					t.Fatalf("key %q: got %v, want %v", key, got[key], want)
+				}
+			}
+			for key := range got {
+				if strings.EqualFold(key, "access") && key != "access" {
+					t.Fatalf("case variant %q survived in %v", key, got)
+				}
+				if strings.EqualFold(key, "error") && key != "error" {
+					t.Fatalf("case variant %q survived in %v", key, got)
+				}
+			}
+		})
+	}
+}