From 2bb851dd506fc451fbcbfcd16d52bd57b767b91c Mon Sep 17 00:00:00 2001 From: n0ctal <4c866w5fn9@privaterelay.appleid.com> Date: Sat, 20 Jun 2026 03:37:35 +0500 Subject: [PATCH] fix(xray): verify the release archive checksum before installing (#5396) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(xray): verify the release archive checksum before installing UpdateXray downloaded the Xray-core release zip and installed the binary from it after only a TLS fetch, an HTTP-200 check and a size cap — the archive itself was never verified, so a corrupted or tampered release asset would be extracted and run as the panel's xray binary. Verify the downloaded archive against the SHA2-256 published in the release's .dgst sidecar (which XTLS ships next to every asset) before installing, and abort the update on mismatch, a missing/short SHA2-256 entry, or an unreachable .dgst. The digest parser and fetch are covered by tests, including the real .dgst line format ("SHA2-256= "). * address review: clearer warning + re-download guidance on checksum mismatch Per review feedback on the PR: on a SHA-256 mismatch, surface a plain-language warning that the downloaded archive is corrupted or differs from the official release and that the user should exit and re-download, instead of a terse "checksum mismatch" error. The install still aborts so a mismatched binary is never run; the message now tells the user the safe next step. --- internal/web/service/server.go | 62 ++++++++++++++++ .../web/service/server_xray_checksum_test.go | 72 +++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 internal/web/service/server_xray_checksum_test.go diff --git a/internal/web/service/server.go b/internal/web/service/server.go index d568d1daa..29501abf6 100644 --- a/internal/web/service/server.go +++ b/internal/web/service/server.go @@ -4,6 +4,8 @@ import ( "archive/zip" "bufio" "bytes" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "io" @@ -685,6 +687,9 @@ func (s *ServerService) sampleCPUUtilization() (float64, error) { const ( maxXrayArchiveBytes = 200 << 20 maxXrayBinaryBytes = 200 << 20 + // maxXrayDigestBytes caps the .dgst checksum sidecar read; it is a few + // hundred bytes in practice. + maxXrayDigestBytes = 64 << 10 ) func (s *ServerService) GetXrayVersions() ([]string, error) { @@ -826,10 +831,67 @@ func (s *ServerService) downloadXRay(version string) (string, error) { return "", fmt.Errorf("download xray: archive exceeds %d bytes", maxXrayArchiveBytes) } + // Verify the archive against the SHA2-256 published in the release's .dgst + // sidecar before installing it. TLS protects the transport, not the artifact; + // a corrupted or tampered asset must not be installed and run as xray. + want, err := s.fetchXrayDigestSHA256(client, url+".dgst") + if err != nil { + return "", err + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + return "", err + } + hasher := sha256.New() + if _, err := io.Copy(hasher, file); err != nil { + return "", err + } + if got := hex.EncodeToString(hasher.Sum(nil)); !strings.EqualFold(got, want) { + // User-facing warning: the archive's SHA-256 does not match the official + // release checksum, so the download is corrupted or has been tampered + // with. Abort the install so a bad binary is never run, and tell the user + // to retry/re-download rather than proceed with a mismatched image. + return "", fmt.Errorf("Xray update aborted: the downloaded archive does not match the official SHA-256 checksum, so the image is corrupted or differs from the official release. Please exit and re-download the official image, then try again (expected %s, got %s)", want, got) + } + ok = true return path, nil } +// fetchXrayDigestSHA256 downloads the .dgst sidecar XTLS publishes next to each +// release asset and returns the SHA2-256 hex digest it lists. +func (s *ServerService) fetchXrayDigestSHA256(client *http.Client, dgstURL string) (string, error) { + resp, err := client.Get(dgstURL) + if err != nil { + return "", fmt.Errorf("download xray checksum: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("download xray checksum: unexpected HTTP %d", resp.StatusCode) + } + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxXrayDigestBytes)) + if err != nil { + return "", fmt.Errorf("download xray checksum: %w", err) + } + return parseXrayDigestSHA256(raw) +} + +// parseXrayDigestSHA256 extracts the lowercase SHA2-256 hex from an XTLS .dgst +// file, whose lines are "ALGO= " (the relevant one being "SHA2-256= ..."). +func parseXrayDigestSHA256(dgst []byte) (string, error) { + for _, line := range strings.Split(string(dgst), "\n") { + rest, ok := strings.CutPrefix(strings.TrimSpace(line), "SHA2-256=") + if !ok { + continue + } + h := strings.ToLower(strings.TrimSpace(rest)) + if len(h) != 64 { + return "", fmt.Errorf("xray checksum: malformed SHA2-256 entry in digest") + } + return h, nil + } + return "", fmt.Errorf("xray checksum: no SHA2-256 entry in digest") +} + func (s *ServerService) UpdateXray(version string) error { versions, err := s.GetXrayVersions() if err != nil { diff --git a/internal/web/service/server_xray_checksum_test.go b/internal/web/service/server_xray_checksum_test.go new file mode 100644 index 000000000..b50e86a67 --- /dev/null +++ b/internal/web/service/server_xray_checksum_test.go @@ -0,0 +1,72 @@ +package service + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// A real XTLS .dgst sidecar (Xray-linux-64.zip.dgst, v26.3.27): lines are +// "ALGO= ", and the algorithm label is "SHA2-256", not "SHA256". +const sampleXrayDgst = `# Hash Values + +MD5= ee4e2ff74948a9b464624b1cabc44409 +SHA1= b55b06e74e89083b9cedfdecf0d68b579cd2af72 +SHA2-256= 23cd9af937744d97776ee35ecad4972cf4b2109d1e0fe6be9930467608f7c8ae +SHA2-512= e8bc40a0687cac184bbe4b5c1f047e69064ccedc489fb25e208889ae287bbf8736dff16b108d68fc00dc33edc8bb53502e47a9698a277f4f51b67b83d899e518 +` + +const wantSHA = "23cd9af937744d97776ee35ecad4972cf4b2109d1e0fe6be9930467608f7c8ae" + +func TestParseXrayDigestSHA256(t *testing.T) { + got, err := parseXrayDigestSHA256([]byte(sampleXrayDgst)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if got != wantSHA { + t.Fatalf("sha = %q, want %q", got, wantSHA) + } +} + +func TestParseXrayDigestSHA256_Errors(t *testing.T) { + for _, tc := range []struct { + name string + in string + }{ + {"no-sha256-line", "MD5= abc\nSHA1= def\n"}, + {"malformed-short", "SHA2-256= deadbeef\n"}, + {"empty", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := parseXrayDigestSHA256([]byte(tc.in)); err == nil { + t.Fatalf("%s: expected an error", tc.name) + } + }) + } +} + +func TestFetchXrayDigestSHA256(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(sampleXrayDgst)) + })) + defer srv.Close() + + got, err := (&ServerService{}).fetchXrayDigestSHA256(srv.Client(), srv.URL+"/Xray-linux-64.zip.dgst") + if err != nil { + t.Fatalf("fetch: %v", err) + } + if got != wantSHA { + t.Fatalf("sha = %q, want %q", got, wantSHA) + } +} + +func TestFetchXrayDigestSHA256_HTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "nope", http.StatusNotFound) + })) + defer srv.Close() + + if _, err := (&ServerService{}).fetchXrayDigestSHA256(srv.Client(), srv.URL+"/missing.dgst"); err == nil { + t.Fatal("expected an error on HTTP 404") + } +}