diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index 3a94b2b53..da7be4d23 100644 --- a/internal/web/service/xray.go +++ b/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)) diff --git a/internal/web/service/xray_log_confine_test.go b/internal/web/service/xray_log_confine_test.go new file mode 100644 index 000000000..ed06f199c --- /dev/null +++ b/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) + } + } + }) + } +}