Просмотр исходного кода

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.
n0ctal 1 день назад
Родитель
Сommit
2bb851dd50
2 измененных файлов с 134 добавлено и 0 удалено
  1. 62 0
      internal/web/service/server.go
  2. 72 0
      internal/web/service/server_xray_checksum_test.go

+ 62 - 0
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= <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 {
 	versions, err := s.GetXrayVersions()
 	if err != nil {

+ 72 - 0
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= <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")
+	}
+}