diff --git a/bgptools/asn_metadata.go b/bgptools/asn_metadata.go index 0a26073..94f66f7 100644 --- a/bgptools/asn_metadata.go +++ b/bgptools/asn_metadata.go @@ -168,13 +168,16 @@ func validateASNMetadataManifest(data, snapshot []byte, count int, generatedAt t func fetchASNMetadata(ctx context.Context, client *http.Client, snapshotURL string) ([]byte, error) { request, err := http.NewRequestWithContext(ctx, http.MethodGet, snapshotURL, nil) if err != nil { - return nil, err + return nil, errors.New("create ASN metadata request failed") } request.Header.Set("Accept", "application/json") request.Header.Set("User-Agent", "oneclickvirt-backtrace-asn-metadata/1") response, err := client.Do(request) if err != nil { - return nil, err + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr + } + return nil, errors.New("ASN metadata request failed") } defer response.Body.Close() if response.StatusCode != http.StatusOK { @@ -183,7 +186,7 @@ func fetchASNMetadata(ctx context.Context, client *http.Client, snapshotURL stri const maximumSize = 4 << 20 data, err := io.ReadAll(io.LimitReader(response.Body, maximumSize+1)) if err != nil { - return nil, err + return nil, errors.New("ASN metadata response read failed") } if len(data) > maximumSize { return nil, fmt.Errorf("ASN metadata exceeds %d bytes", maximumSize) diff --git a/bgptools/origin.go b/bgptools/origin.go index 2bdbcb9..8ea24ff 100644 --- a/bgptools/origin.go +++ b/bgptools/origin.go @@ -53,19 +53,22 @@ func ResolveOriginASNWithConfig(parent context.Context, ip string, config Origin } requestURL, err := addQuery(config.BaseURL, "resource", parsed.String()) if err != nil { - return "", err + return "", errors.New("invalid origin ASN source") } ctx, cancel := context.WithTimeout(parent, config.Timeout) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) if err != nil { - return "", err + return "", errors.New("create origin ASN request failed") } req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", "oneclickvirt-backtrace-origin-asn/1") response, err := config.Client.Do(req) if err != nil { - return "", err + if ctxErr := ctx.Err(); ctxErr != nil { + return "", ctxErr + } + return "", errors.New("origin ASN request failed") } defer response.Body.Close() if response.StatusCode == http.StatusTooManyRequests { @@ -76,7 +79,7 @@ func ResolveOriginASNWithConfig(parent context.Context, ip string, config Origin } body, err := io.ReadAll(io.LimitReader(response.Body, config.MaxResponseSize+1)) if err != nil { - return "", err + return "", errors.New("origin ASN response read failed") } if int64(len(body)) > config.MaxResponseSize { return "", fmt.Errorf("origin ASN response exceeds %d bytes", config.MaxResponseSize) diff --git a/bgptools/privacy_test.go b/bgptools/privacy_test.go new file mode 100644 index 0000000..dc66536 --- /dev/null +++ b/bgptools/privacy_test.go @@ -0,0 +1,47 @@ +package bgptools + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" +) + +type privacyRoundTripper func(*http.Request) (*http.Response, error) + +func (fn privacyRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { + return fn(request) +} + +func TestRemoteFetchErrorsDoNotExposeSourceURL(t *testing.T) { + client := &http.Client{Transport: privacyRoundTripper(func(request *http.Request) (*http.Response, error) { + return nil, errors.New("dial " + request.URL.String()) + })} + tests := []struct { + name string + run func() error + }{ + {name: "ASN metadata", run: func() error { + _, err := fetchASNMetadata(context.Background(), client, "https://private.example/asn?token=secret") + return err + }}, + {name: "RDAP", run: func() error { + _, err := QueryRDAP(context.Background(), "192.0.2.1", client, "https://private.example/rdap?token=secret") + return err + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.run() + if err == nil { + t.Fatal("expected request failure") + } + for _, forbidden := range []string{"private.example", "token=", "secret"} { + if strings.Contains(err.Error(), forbidden) { + t.Fatalf("error leaked %q: %v", forbidden, err) + } + } + }) + } +} diff --git a/bgptools/rdap.go b/bgptools/rdap.go index 832d13d..dfbece4 100644 --- a/bgptools/rdap.go +++ b/bgptools/rdap.go @@ -60,13 +60,16 @@ func QueryRDAP(ctx context.Context, ip string, client *http.Client, baseURL stri requestURL := strings.TrimRight(baseURL, "/") + "/" + parsed.String() req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) if err != nil { - return RDAPRecord{}, err + return RDAPRecord{}, errors.New("create RDAP request failed") } req.Header.Set("Accept", "application/rdap+json, application/json") req.Header.Set("User-Agent", "oneclickvirt-backtrace-rdap/1") response, err := client.Do(req) if err != nil { - return RDAPRecord{}, err + if ctxErr := ctx.Err(); ctxErr != nil { + return RDAPRecord{}, ctxErr + } + return RDAPRecord{}, errors.New("RDAP request failed") } defer response.Body.Close() if response.StatusCode == http.StatusTooManyRequests { @@ -77,7 +80,7 @@ func QueryRDAP(ctx context.Context, ip string, client *http.Client, baseURL stri } data, err := io.ReadAll(io.LimitReader(response.Body, (4<<20)+1)) if err != nil { - return RDAPRecord{}, err + return RDAPRecord{}, errors.New("RDAP response read failed") } if len(data) > 4<<20 { return RDAPRecord{}, ErrRDAPResponseTooLarge diff --git a/cmd/main.go b/cmd/main.go index 8c143a7..a53bad9 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -96,7 +96,7 @@ func main() { return } if err := validateStructuredOptions(options); err != nil { - fmt.Fprintln(os.Stderr, err) + fmt.Fprintln(os.Stderr, sanitizeErrorText(err.Error())) os.Exit(2) } if options.jsonOutput { @@ -109,7 +109,7 @@ func main() { }, } if err := writeStructuredReport(context.Background(), os.Stdout, options.specifiedIP, config, bgptools.QueryIPBGPReport); err != nil { - fmt.Fprintf(os.Stderr, "structured report failed: %v\n", err) + fmt.Fprintf(os.Stderr, "structured report failed: %s\n", sanitizeErrorText(err.Error())) os.Exit(1) } return @@ -118,12 +118,12 @@ func main() { if options.showIPInfo { rsp, err := http.Get("http://ipinfo.io") if err != nil { - fmt.Printf("get ip info err %v \n", err.Error()) + fmt.Printf("get ip info err %s \n", sanitizeErrorText(err.Error())) } else { defer rsp.Body.Close() err = json.NewDecoder(rsp.Body).Decode(&info) if err != nil { - fmt.Printf("json decode err %v \n", err.Error()) + fmt.Printf("json decode err %s \n", sanitizeErrorText(err.Error())) } else { fmt.Println(Green("国家: ") + White(info.Country) + Green(" 城市: ") + White(info.City) + Green(" 服务商: ") + Blue(info.Org)) @@ -186,10 +186,10 @@ func main() { }) wg.Wait() if results.bgpResult != "" { - fmt.Print(results.bgpResult) + fmt.Print(indentLegacyOutput(results.bgpResult)) } if results.backtraceResult != "" { - fmt.Printf("%s\n", results.backtraceResult) + fmt.Printf("%s\n", indentLegacyOutput(results.backtraceResult)) } fmt.Println(Yellow("准确线路自行查看详细路由,本测试结果仅作参考")) fmt.Println(Yellow("同一目标地址多个线路时,检测可能已越过汇聚层,除第一个线路外,后续信息可能无效")) diff --git a/cmd/output.go b/cmd/output.go new file mode 100644 index 0000000..16b552c --- /dev/null +++ b/cmd/output.go @@ -0,0 +1,27 @@ +package main + +import ( + "regexp" + "strings" +) + +var privateErrorPattern = regexp.MustCompile("(?i)(https?://)[^\\s]+") +var privateTokenPattern = regexp.MustCompile("(?i)(^|[?&\\s])(token|api[_-]?key|secret|authorization|password|key)=([^&\\s]+)") +var privatePathPattern = regexp.MustCompile("(?:[A-Za-z]:\\\\|/)[^\\s]+") + +func indentLegacyOutput(value string) string { + lines := strings.Split(value, "\n") + for index, line := range lines { + if line != "" && line[0] != ' ' && line[0] != '\t' { + lines[index] = " " + line + } + } + return strings.Join(lines, "\n") +} + +// sanitizeErrorText keeps remote-source and credential details out of user-visible errors. +func sanitizeErrorText(value string) string { + value = privateErrorPattern.ReplaceAllString(value, "") + value = privateTokenPattern.ReplaceAllString(value, "$1$2=") + return privatePathPattern.ReplaceAllString(value, "") +} diff --git a/cmd/output_test.go b/cmd/output_test.go new file mode 100644 index 0000000..8c066be --- /dev/null +++ b/cmd/output_test.go @@ -0,0 +1,24 @@ +package main + +import ( + "strings" + "testing" +) + +func TestIndentLegacyOutputLeavesOneLeadingCell(t *testing.T) { + got := indentLegacyOutput("alpha\n beta\n\tgamma\n") + want := " alpha\n beta\n\tgamma\n" + if got != want { + t.Fatalf("indentLegacyOutput() = %q, want %q", got, want) + } +} + +func TestSanitizeErrorTextRedactsRemoteSecretsAndPaths(t *testing.T) { + input := "load https://private.example/repo/data?token=secret from /private/cache failed: api_key=hidden" + got := sanitizeErrorText(input) + for _, forbidden := range []string{"private.example", "secret", "hidden", "/private/cache"} { + if strings.Contains(got, forbidden) { + t.Fatalf("sanitizeErrorText() leaked %q in %q", forbidden, got) + } + } +} diff --git a/model/model.go b/model/model.go index 232005b..8b2b92d 100644 --- a/model/model.go +++ b/model/model.go @@ -2,7 +2,7 @@ package model import "time" -const BackTraceVersion = "v0.0.15" +const BackTraceVersion = "v0.0.16" var EnableLoger = false