diff options
| author | pwnnex <pwnnex@proton.me> | 2026-04-21 21:30:02 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-21 21:30:02 +0300 |
| commit | 15be803da982ab362f6859c0ec91a582c4b1fea6 (patch) | |
| tree | a4e716380ede864b97ef98b6dccb05926ffec32f /web/service/xray_setting_test.go | |
| parent | c79b45e512bd41d24f668a373fdabb922c437877 (diff) | |
Fix blank Xray Settings page from wrapped xrayTemplateConfig (#4059) (#4069)
`getXraySetting` builds its response as
{ "xraySetting": <db value>, "inboundTags": ..., "outboundTestUrl": ... }
and embeds the raw DB value as the `xraySetting` field without
checking whether the stored value already has that exact shape.
The frontend pulls the textarea content from `result.xraySetting`
and saves it back verbatim. If the DB ever ends up holding the
response-shaped wrapper instead of a real xray config (older
installs where this happened at least once, users who imported a
copy-pasted response into the textarea, a botched migration, etc.),
the next save nests another layer, the one after that nests a
third, and the Vue-side JSON.parse of the resulting blob silently
fails — the Xray Settings page goes blank.
Fix both ends of the round-trip:
* Add `service.UnwrapXrayTemplateConfig`. It peels off any number of
`xraySetting`-keyed layers, leaving a real xray config behind.
The check is conservative: if the outer object already contains
any top-level xray key (`inbounds`, `outbounds`, `routing`, `api`,
`dns`, `log`, `policy`, `stats`), it is returned unchanged, and
there is a depth cap to avoid pathological inputs.
* `SaveXraySetting` unwraps before validation so a round-tripped
wrapper from an already-corrupted page can no longer re-poison
the DB on save.
* `getXraySetting` unwraps on read and, when it finds a wrapper,
rewrites the DB with the corrected value. Existing broken installs
heal themselves on the next visit to the page.
Includes unit tests for the passthrough, single-wrap, multi-wrap,
string-encoded-inner, and false-positive cases.
Co-authored-by: pwnnex <eternxles@gmail.com>
Diffstat (limited to 'web/service/xray_setting_test.go')
| -rw-r--r-- | web/service/xray_setting_test.go | 90 |
1 files changed, 90 insertions, 0 deletions
diff --git a/web/service/xray_setting_test.go b/web/service/xray_setting_test.go new file mode 100644 index 00000000..2c165576 --- /dev/null +++ b/web/service/xray_setting_test.go @@ -0,0 +1,90 @@ +package service + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestUnwrapXrayTemplateConfig(t *testing.T) { + real := `{"log":{},"inbounds":[],"outbounds":[],"routing":{}}` + + t.Run("passes through a clean config", func(t *testing.T) { + if got := UnwrapXrayTemplateConfig(real); got != real { + t.Fatalf("clean config was modified: %s", got) + } + }) + + t.Run("passes through invalid JSON unchanged", func(t *testing.T) { + in := "not json at all" + if got := UnwrapXrayTemplateConfig(in); got != in { + t.Fatalf("invalid input was modified: %s", got) + } + }) + + t.Run("unwraps one layer of response-shaped wrapper", func(t *testing.T) { + wrapper := `{"inboundTags":["tag"],"outboundTestUrl":"x","xraySetting":` + real + `}` + got := UnwrapXrayTemplateConfig(wrapper) + if !equalJSON(t, got, real) { + t.Fatalf("want %s, got %s", real, got) + } + }) + + t.Run("unwraps multiple stacked layers", func(t *testing.T) { + lvl1 := `{"xraySetting":` + real + `}` + lvl2 := `{"xraySetting":` + lvl1 + `}` + lvl3 := `{"xraySetting":` + lvl2 + `}` + got := UnwrapXrayTemplateConfig(lvl3) + if !equalJSON(t, got, real) { + t.Fatalf("want %s, got %s", real, got) + } + }) + + t.Run("handles an xraySetting stored as a JSON-encoded string", func(t *testing.T) { + encoded, _ := json.Marshal(real) // becomes a quoted string + wrapper := `{"xraySetting":` + string(encoded) + `}` + got := UnwrapXrayTemplateConfig(wrapper) + if !equalJSON(t, got, real) { + t.Fatalf("want %s, got %s", real, got) + } + }) + + t.Run("does not unwrap when top level already has real xray keys", func(t *testing.T) { + // Pathological but defensible: if a user's actual config somehow + // has both the real keys and an unrelated `xraySetting` key, we + // must not strip it. + in := `{"inbounds":[],"xraySetting":{"some":"thing"}}` + got := UnwrapXrayTemplateConfig(in) + if got != in { + t.Fatalf("should have left real config alone, got %s", got) + } + }) + + t.Run("stops at a reasonable depth", func(t *testing.T) { + // Build a deeper-than-maxDepth chain that ends at something + // non-wrapped, and confirm we end up at some valid JSON (we + // don't loop forever and we don't blow the stack). + s := real + for i := 0; i < 16; i++ { + s = `{"xraySetting":` + s + `}` + } + got := UnwrapXrayTemplateConfig(s) + if !strings.Contains(got, `"inbounds"`) && !strings.Contains(got, `"xraySetting"`) { + t.Fatalf("unexpected tail: %s", got) + } + }) +} + +func equalJSON(t *testing.T, a, b string) bool { + t.Helper() + var va, vb any + if err := json.Unmarshal([]byte(a), &va); err != nil { + return false + } + if err := json.Unmarshal([]byte(b), &vb); err != nil { + return false + } + ja, _ := json.Marshal(va) + jb, _ := json.Marshal(vb) + return string(ja) == string(jb) +} |
