fix(xray): verify the release archive checksum before installing (#5396)

* 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= <hex>").

* 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.
This commit is contained in:
n0ctal
2026-06-20 03:37:35 +05:00
committed by GitHub
parent abffa8f6c9
commit 2bb851dd50
2 changed files with 134 additions and 0 deletions
+62
View File
@@ -4,6 +4,8 @@ import (
"archive/zip" "archive/zip"
"bufio" "bufio"
"bytes" "bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -685,6 +687,9 @@ func (s *ServerService) sampleCPUUtilization() (float64, error) {
const ( const (
maxXrayArchiveBytes = 200 << 20 maxXrayArchiveBytes = 200 << 20
maxXrayBinaryBytes = 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) { 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) 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 ok = true
return path, nil 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= <hex>" (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 { func (s *ServerService) UpdateXray(version string) error {
versions, err := s.GetXrayVersions() versions, err := s.GetXrayVersions()
if err != nil { if err != nil {
@@ -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= <hex>", 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")
}
}