From cbc1febba60803cad51df98ae22b2e0d3b98c481 Mon Sep 17 00:00:00 2001 From: rongfeng Date: Tue, 11 Aug 2026 16:36:36 +0800 Subject: [PATCH] Validate subscription responses before replacing config --- core/src/main/golang/native/config/fetch.go | 35 +++++++- .../main/golang/native/config/fetch_test.go | 84 +++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 core/src/main/golang/native/config/fetch_test.go diff --git a/core/src/main/golang/native/config/fetch.go b/core/src/main/golang/native/config/fetch.go index c89cf4a847..e84953e74b 100644 --- a/core/src/main/golang/native/config/fetch.go +++ b/core/src/main/golang/native/config/fetch.go @@ -1,6 +1,7 @@ package config import ( + "bytes" "context" "encoding/json" "fmt" @@ -18,6 +19,7 @@ import ( "github.com/metacubex/mihomo/adapter/provider" clashHttp "github.com/metacubex/mihomo/component/http" + mihomoConfig "github.com/metacubex/mihomo/config" RB "github.com/metacubex/mihomo/rules/bundle" ) @@ -44,6 +46,11 @@ func openUrl(ctx context.Context, url string) (io.ReadCloser, fetchHeader, error if err != nil { return nil, fetchHeader{}, err } + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + _ = response.Body.Close() + + return nil, fetchHeader{}, fmt.Errorf("subscription request failed: %s", response.Status) + } return response.Body, fetchHeader{ SubscriptionUserInfo: response.Header.Get("subscription-userinfo"), @@ -56,6 +63,20 @@ func openContent(url string) (io.ReadCloser, error) { } func fetch(url *U.URL, file string) (fetchHeader, error) { + return fetchWithValidator(url, file, nil) +} + +func fetchConfiguration(url *U.URL, file string) (fetchHeader, error) { + return fetchWithValidator(url, file, func(data []byte) error { + if _, err := mihomoConfig.UnmarshalRawConfig(data); err != nil { + return fmt.Errorf("invalid configuration response: %w", err) + } + + return nil + }) +} + +func fetchWithValidator(url *U.URL, file string, validate func([]byte) error) (fetchHeader, error) { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() @@ -78,6 +99,18 @@ func fetch(url *U.URL, file string) (fetchHeader, error) { defer reader.Close() + if validate != nil { + data, err := io.ReadAll(reader) + if err != nil { + return fetchHeader{}, err + } + if err := validate(data); err != nil { + return fetchHeader{}, err + } + + return header, writeFile(file, bytes.NewReader(data)) + } + return header, writeFile(file, reader) } @@ -171,7 +204,7 @@ func FetchAndValid( reportStatus(string(bytes)) - header, err := fetch(url, configPath) + header, err := fetchConfiguration(url, configPath) if err != nil { return err } diff --git a/core/src/main/golang/native/config/fetch_test.go b/core/src/main/golang/native/config/fetch_test.go new file mode 100644 index 0000000000..365aa994aa --- /dev/null +++ b/core/src/main/golang/native/config/fetch_test.go @@ -0,0 +1,84 @@ +package config + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + U "net/url" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestOpenURLRejectsHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "upstream unavailable", http.StatusBadGateway) + })) + t.Cleanup(server.Close) + + body, _, err := openUrl(context.Background(), server.URL) + if body != nil { + _ = body.Close() + t.Fatal("expected no response body for an HTTP error") + } + if err == nil || !strings.Contains(err.Error(), "502 Bad Gateway") { + t.Fatalf("expected HTTP status error, got %v", err) + } +} + +func TestFetchConfigurationRejectsInvalidResponseWithoutOverwriting(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = fmt.Fprint(w, "检测到不受支持的客户端") + })) + t.Cleanup(server.Close) + + profileDir := t.TempDir() + configPath := filepath.Join(profileDir, "config.yaml") + original := []byte("proxies: []\nrules: []\n") + if err := os.WriteFile(configPath, original, 0600); err != nil { + t.Fatalf("write existing configuration: %v", err) + } + + url, err := U.Parse(server.URL) + if err != nil { + t.Fatalf("parse server URL: %v", err) + } + if _, err := fetchConfiguration(url, configPath); err == nil || !strings.Contains(err.Error(), "invalid configuration response") { + t.Fatalf("expected invalid configuration error, got %v", err) + } + + current, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("read existing configuration: %v", err) + } + if string(current) != string(original) { + t.Fatal("invalid response overwrote the existing configuration") + } +} + +func TestFetchConfigurationWritesValidResponse(t *testing.T) { + configuration := []byte("proxies: []\nrules: []\n") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(configuration) + })) + t.Cleanup(server.Close) + + url, err := U.Parse(server.URL) + if err != nil { + t.Fatalf("parse server URL: %v", err) + } + configPath := filepath.Join(t.TempDir(), "config.yaml") + if _, err := fetchConfiguration(url, configPath); err != nil { + t.Fatalf("fetch valid configuration: %v", err) + } + + written, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("read fetched configuration: %v", err) + } + if string(written) != string(configuration) { + t.Fatal("fetched configuration does not match the response") + } +}