From 958ccc5c73b3ad6b0cd99fcfb6c4fa344f44965b Mon Sep 17 00:00:00 2001 From: Sasha Mitchell Date: Sat, 8 Aug 2026 15:59:53 +0000 Subject: [PATCH] fix(faro): block private IPs on sourcemap downloads faro.receiver sourcemap download used a bare http.Client with default download_from_origins=["*"], so client-controlled Filename / sourceMappingURL values could reach loopback, RFC1918, link-local, and CGNAT addresses. Reject blocked addresses before Get, re-check origin allowlist on resolved sourceMappingURL, and validate redirect hops. --- .../component/faro/receiver/sourcemaps.go | 62 ++++++++++++++++++- .../faro/receiver/sourcemaps_test.go | 53 ++++++++++++++-- 2 files changed, 110 insertions(+), 5 deletions(-) diff --git a/internal/component/faro/receiver/sourcemaps.go b/internal/component/faro/receiver/sourcemaps.go index 9e8623bf34f..d9617378eef 100644 --- a/internal/component/faro/receiver/sourcemaps.go +++ b/internal/component/faro/receiver/sourcemaps.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "io" + "net" "io/fs" "log/slog" "net/http" @@ -145,7 +146,15 @@ func newSourceMapsStore(log *slog.Logger, args SourceMapsArguments, metrics *sou // client. if cli == nil { - cli = &http.Client{Timeout: args.DownloadTimeout} + cli = &http.Client{ + Timeout: args.DownloadTimeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if isUnsafeDownloadURL(req.URL.String()) { + return fmt.Errorf("refusing redirect to blocked address") + } + return nil + }, + } } if fs == nil { fs = osFileService{} @@ -460,6 +469,10 @@ func (store *sourceMapsStoreImpl) downloadSourceMapContent(sourceURL string) (co store.log.Debug("resolved absolute source map URL", "url", sourceURL, "sourceMapURL", sourceMapURL) } + if !urlMatchesOrigins(resolvedSourceMapURL, store.args.DownloadFromOrigins) { + store.log.Debug("resolved source map url origin not allowed", "url", resolvedSourceMapURL) + return nil, "", fmt.Errorf("source map url origin not allowed") + } store.log.Debug("attempting to download source map file", "url", resolvedSourceMapURL) result, err = store.downloadFileContents(resolvedSourceMapURL) if err != nil { @@ -471,6 +484,10 @@ func (store *sourceMapsStoreImpl) downloadSourceMapContent(sourceURL string) (co } func (store *sourceMapsStoreImpl) downloadFileContents(url string) ([]byte, error) { + if isUnsafeDownloadURL(url) { + store.metrics.downloads.WithLabelValues(getOrigin(url), "blocked").Inc() + return nil, fmt.Errorf("refusing download to blocked address") + } resp, err := store.cli.Get(url) if err != nil { store.metrics.downloads.WithLabelValues(getOrigin(url), "?").Inc() @@ -492,6 +509,49 @@ func (store *sourceMapsStoreImpl) downloadFileContents(url string) ([]byte, erro var reSourceMap = regexp.MustCompile("//[#@]\\s(source(?:Mapping)?URL)=\\s*(?P\\S+)\r?\n?$") + +func isBlockedIP(ip net.IP) bool { + if ip == nil { + return true + } + if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() { + return true + } + // CGNAT / shared address space (RFC 6598) + if ip4 := ip.To4(); ip4 != nil && ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 { + return true + } + return false +} + +// isUnsafeDownloadURL rejects loopback/private/link-local/CGNAT targets for +// faro.receiver sourcemap downloads (client-controlled Filename / sourceMappingURL). +func isUnsafeDownloadURL(raw string) bool { + u, err := url.Parse(raw) + if err != nil || u.Hostname() == "" { + return true + } + if u.Scheme != "http" && u.Scheme != "https" { + return true + } + host := u.Hostname() + if ip := net.ParseIP(host); ip != nil { + return isBlockedIP(ip) + } + ips, err := net.LookupIP(host) + if err != nil || len(ips) == 0 { + // Unknown host: let the HTTP client fail. Still block when DNS + // succeeds and points at a non-global address. + return false + } + for _, ip := range ips { + if isBlockedIP(ip) { + return true + } + } + return false +} + func getOrigin(URL string) string { // TODO(rfratto): why are we parsing this every time? Let's parse it once. diff --git a/internal/component/faro/receiver/sourcemaps_test.go b/internal/component/faro/receiver/sourcemaps_test.go index 03a479e5ab9..bb4ef5e2464 100644 --- a/internal/component/faro/receiver/sourcemaps_test.go +++ b/internal/component/faro/receiver/sourcemaps_test.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "path/filepath" + "net" "testing" "time" @@ -100,7 +101,7 @@ func Test_sourceMapsStoreImpl_DownloadSuccess(t *testing.T) { } actual := transformException(logger, store, mockException(), "123") - require.Equal(t, []string{"http://localhost:1234/foo.js", "http://localhost:1234/foo.js.map"}, httpClient.requests) + require.Equal(t, []string{"http://example.com:1234/foo.js", "http://example.com:1234/foo.js.map"}, httpClient.requests) require.Equal(t, expect, actual) } @@ -134,7 +135,7 @@ func Test_sourceMapsStoreImpl_DownloadError(t *testing.T) { expect := mockException() actual := transformException(logger, store, expect, "123") - require.Equal(t, []string{"http://localhost:1234/foo.js"}, httpClient.requests) + require.Equal(t, []string{"http://example.com:1234/foo.js"}, httpClient.requests) require.Equal(t, expect, actual) } @@ -1076,13 +1077,13 @@ func mockException() *payload.Exception { Frames: []payload.Frame{ { Colno: 6, - Filename: "http://localhost:1234/foo.js", + Filename: "http://example.com:1234/foo.js", Function: "eval", Lineno: 5, }, { Colno: 5, - Filename: "http://localhost:1234/foo.js", + Filename: "http://example.com:1234/foo.js", Function: "callUndefined", Lineno: 6, }, @@ -1109,3 +1110,47 @@ func newTestFileService() *testFileService { reads: make([]string, 0), } } + + +func Test_isUnsafeDownloadURL(t *testing.T) { + t.Parallel() + require.True(t, isUnsafeDownloadURL("http://127.0.0.1/map.js")) + require.True(t, isUnsafeDownloadURL("http://169.254.169.254/latest/meta-data/")) + require.True(t, isUnsafeDownloadURL("http://10.0.0.1/x.js")) + require.True(t, isUnsafeDownloadURL("http://[::1]/8080/x.js")) + require.True(t, isUnsafeDownloadURL("file:///etc/passwd")) +} + +func Test_isBlockedIP(t *testing.T) { + t.Parallel() + require.True(t, isBlockedIP(net.ParseIP("127.0.0.1"))) + require.True(t, isBlockedIP(net.ParseIP("10.1.2.3"))) + require.True(t, isBlockedIP(net.ParseIP("192.168.1.1"))) + require.True(t, isBlockedIP(net.ParseIP("169.254.169.254"))) + require.True(t, isBlockedIP(net.ParseIP("100.64.0.1"))) + require.False(t, isBlockedIP(net.ParseIP("8.8.8.8"))) +} + + +func Test_sourceMapsStoreImpl_BlockPrivateDownloadURL(t *testing.T) { + var ( + logger = alloyutil.TestAlloyLogger(t).Slog() + httpClient = &mockHTTPClient{} + store = newSourceMapsStore( + logger, + SourceMapsArguments{Download: true, DownloadFromOrigins: []string{"*"}}, + newSourceMapMetrics(prometheus.NewRegistry()), + httpClient, + newTestFileService(), + ) + ) + ex := &payload.Exception{Stacktrace: &payload.Stacktrace{Frames: []payload.Frame{{ + Filename: "http://169.254.169.254/latest/meta-data/", + Lineno: 1, + Colno: 1, + Function: "x", + }}}} + actual := transformException(logger, store, ex, "1") + require.Equal(t, ex, actual) + require.Empty(t, httpClient.requests) +}