From 881d397bbc800ce73cf6e187f090681f1b5698d6 Mon Sep 17 00:00:00 2001 From: spiritlhl <103393591+spiritLHLS@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:51:44 +0800 Subject: [PATCH] fix: redact structured source details --- bgptools/privacy_test.go | 50 +++++++++++++++++++++++++++++++++ bgptools/rdap.go | 2 +- bgptools/relationships.go | 15 ++++++++-- bgptools/report.go | 58 +++++++++++++++++++++++++++++++++++---- model/model.go | 2 +- 5 files changed, 116 insertions(+), 11 deletions(-) diff --git a/bgptools/privacy_test.go b/bgptools/privacy_test.go index dc66536..de9f214 100644 --- a/bgptools/privacy_test.go +++ b/bgptools/privacy_test.go @@ -2,8 +2,11 @@ package bgptools import ( "context" + "encoding/json" "errors" + "fmt" "net/http" + "net/http/httptest" "strings" "testing" ) @@ -14,6 +17,53 @@ func (fn privacyRoundTripper) RoundTrip(request *http.Request) (*http.Response, return fn(request) } +func TestStructuredReportErrorsDoNotExposeRemoteURLs(t *testing.T) { + var rdap *httptest.Server + rdap = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = fmt.Fprintf(w, `{"handle":"TEST","links":[{"rel":"geofeed","href":"https://private.example/geofeed?token=secret"}]}`) + })) + defer rdap.Close() + failing := &http.Client{Transport: privacyRoundTripper(func(request *http.Request) (*http.Response, error) { + return nil, errors.New("dial " + request.URL.String()) + })} + report, err := QueryIPBGPReport(context.Background(), "192.0.2.1", IPBGPReportConfig{ + RDAPClient: rdap.Client(), + RDAPBaseURL: rdap.URL, + FetchGeofeed: true, + GeofeedClient: failing, + ResolveASN: func(context.Context, string) (string, error) { + return "", errors.New("resolver https://private.example/asn?key=secret") + }, + }) + if err != nil { + t.Fatal(err) + } + for _, source := range report.Sources { + for _, field := range []string{source.Source, source.Error} { + for _, forbidden := range []string{"private.example", "token=", "key=", "secret"} { + if strings.Contains(field, forbidden) { + t.Fatalf("structured source leaked %q: %+v", forbidden, source) + } + } + } + } + if len(report.Geofeeds) != 1 || report.Geofeeds[0].Error != "request_failed" { + t.Fatalf("geofeed error was not stabilized: %+v", report.Geofeeds) + } + encoded, err := json.Marshal(report) + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{"token=", "key=", "secret", "userinfo@"} { + if strings.Contains(string(encoded), forbidden) { + t.Fatalf("structured report leaked %q: %s", forbidden, encoded) + } + } + if report.RDAP == nil || len(report.RDAP.GeofeedURLs) != 1 || report.RDAP.GeofeedURLs[0] != "https://private.example/geofeed" || report.Geofeeds[0].URL != "https://private.example/geofeed" { + t.Fatalf("geofeed URL was not sanitized consistently: %+v", report) + } +} + 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()) diff --git a/bgptools/rdap.go b/bgptools/rdap.go index dfbece4..d25f71d 100644 --- a/bgptools/rdap.go +++ b/bgptools/rdap.go @@ -128,7 +128,7 @@ func parseRDAPRecord(ip, source string, data []byte) (RDAPRecord, error) { record := RDAPRecord{ IP: ip, Handle: payload.Handle, Name: payload.Name, Type: payload.Type, Country: payload.Country, StartAddress: payload.StartAddress, EndAddress: payload.EndAddress, - ParentHandle: payload.ParentHandle, Port43: payload.Port43, Status: payload.Status, Source: source, + ParentHandle: payload.ParentHandle, Port43: payload.Port43, Status: payload.Status, Source: "rdap", } for _, event := range payload.Events { switch strings.ToLower(event.Action) { diff --git a/bgptools/relationships.go b/bgptools/relationships.go index 9f96612..a1b68f2 100644 --- a/bgptools/relationships.go +++ b/bgptools/relationships.go @@ -131,7 +131,7 @@ func QueryASNRelationships(ctx context.Context, asn string, cfg RelationshipConf report := &ASNRelationshipReport{TargetASN: normalized} ripeURL, err := addQuery(cfg.RIPEstatURL, "resource", normalized) if err != nil { - return nil, err + return nil, errors.New("invalid relationship source configuration") } ripeRaw, ripeStatus, ripeErr := fetchRelationshipJSON(ctx, cfg, "ripestat", ripeURL) report.SourceStatuses = append(report.SourceStatuses, sourceStatus("ripestat", ripeStatus, ripeErr)) @@ -153,7 +153,7 @@ func QueryASNRelationships(ctx context.Context, asn string, cfg RelationshipConf peeringURL, err := addQuery(cfg.PeeringDBURL, "asn", normalized) if err != nil { - return nil, err + return nil, errors.New("invalid relationship source configuration") } peeringRaw, peeringStatus, peeringErr := fetchRelationshipJSON(ctx, cfg, "peeringdb", peeringURL) report.SourceStatuses = append(report.SourceStatuses, sourceStatus("peeringdb", peeringStatus, peeringErr)) @@ -255,7 +255,16 @@ func sourceStatus(source string, status RelationshipStatus, err error) Relations Timeout: status == RelationshipTimeout, } if err != nil { - item.Error = err.Error() + switch status { + case RelationshipRateLimited: + item.Error = "rate_limited" + case RelationshipTimeout: + item.Error = "timeout" + case RelationshipMissingFields: + item.Error = "missing_fields" + default: + item.Error = "request_failed" + } } return item } diff --git a/bgptools/report.go b/bgptools/report.go index 947f739..e7fe437 100644 --- a/bgptools/report.go +++ b/bgptools/report.go @@ -217,6 +217,12 @@ func QueryIPBGPReport(parent context.Context, ip string, cfg IPBGPReportConfig) geofeedURLs = append(geofeedURLs, report.WHOIS.GeofeedURLs...) } geofeedURLs = uniqueSortedStrings(geofeedURLs) + if report.RDAP != nil { + report.RDAP.GeofeedURLs = sanitizeReportURLs(report.RDAP.GeofeedURLs) + } + if report.WHOIS != nil { + report.WHOIS.GeofeedURLs = sanitizeReportURLs(report.WHOIS.GeofeedURLs) + } for _, geofeedURL := range geofeedURLs { if !cfg.FetchGeofeed { report.Geofeeds = append(report.Geofeeds, GeofeedResult{URL: geofeedURL, Status: ReportUnsupported, Error: "fetch disabled"}) @@ -224,7 +230,7 @@ func QueryIPBGPReport(parent context.Context, ip string, cfg IPBGPReportConfig) } result := fetchGeofeed(ctx, geofeedURL, cfg) report.Geofeeds = append(report.Geofeeds, result) - report.Sources = append(report.Sources, IPBGPSourceStatus{Source: "geofeed:" + geofeedURL, Status: result.Status, Error: result.Error}) + report.Sources = append(report.Sources, IPBGPSourceStatus{Source: "geofeed", Status: result.Status, Error: result.Error}) } asn := strings.TrimSpace(cfg.ASN) @@ -286,7 +292,7 @@ func inferRIR(record RDAPRecord, available bool) RIRInfo { } func fetchGeofeed(parent context.Context, rawURL string, cfg IPBGPReportConfig) GeofeedResult { - result := GeofeedResult{URL: rawURL} + result := GeofeedResult{URL: sanitizeReportURL(rawURL)} u, err := url.Parse(rawURL) if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { result.Status = ReportUnsupported @@ -310,7 +316,7 @@ func fetchGeofeed(parent context.Context, rawURL string, cfg IPBGPReportConfig) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { result.Status = ReportError - result.Error = err.Error() + result.Error = stableReportError(result.Status, err) return result } req.Header.Set("Accept", "text/csv, text/plain, */*") @@ -318,7 +324,7 @@ func fetchGeofeed(parent context.Context, rawURL string, cfg IPBGPReportConfig) resp, err := client.Do(req) if err != nil { result.Status = reportStatusForError(err) - result.Error = err.Error() + result.Error = stableReportError(result.Status, err) return result } defer resp.Body.Close() @@ -341,7 +347,7 @@ func fetchGeofeed(parent context.Context, rawURL string, cfg IPBGPReportConfig) body, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1)) if err != nil { result.Status = reportStatusForError(err) - result.Error = err.Error() + result.Error = stableReportError(result.Status, err) return result } if int64(len(body)) > maxBytes { @@ -517,11 +523,51 @@ func rirNameFromText(value string) string { func sourceStatusForError(source string, status ReportStatus, err error) IPBGPSourceStatus { item := IPBGPSourceStatus{Source: source, Status: status} if err != nil { - item.Error = err.Error() + item.Error = stableReportError(status, err) } return item } +func stableReportError(status ReportStatus, err error) string { + if errors.Is(err, context.Canceled) { + return "canceled" + } + switch status { + case ReportRateLimited: + return "rate_limited" + case ReportTimeout: + return "timeout" + case ReportUnsupported: + return "unsupported" + case ReportMissingFields: + return "missing_fields" + default: + return "request_failed" + } +} + +func sanitizeReportURL(raw string) string { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return "" + } + parsed.User = nil + parsed.RawQuery = "" + parsed.ForceQuery = false + parsed.Fragment = "" + return parsed.String() +} + +func sanitizeReportURLs(values []string) []string { + result := make([]string, 0, len(values)) + for _, value := range values { + if sanitized := sanitizeReportURL(value); sanitized != "" { + result = append(result, sanitized) + } + } + return uniqueSortedStrings(result) +} + func reportStatusForError(err error) ReportStatus { if err == nil { return ReportAvailable diff --git a/model/model.go b/model/model.go index 8b2b92d..0ef4f74 100644 --- a/model/model.go +++ b/model/model.go @@ -2,7 +2,7 @@ package model import "time" -const BackTraceVersion = "v0.0.16" +const BackTraceVersion = "v0.0.17" var EnableLoger = false