From accf2aee7d0a0a518e1928de30efd01f5100eee4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Kopeck=C3=BD?= Date: Thu, 1 Oct 2026 10:56:01 +0200 Subject: [PATCH 1/3] fix: preserve bare IP addresses in HTTP targets --- cmd/common.go | 4 +- cmd/http.go | 48 +++++++++++++++--------- cmd/http_test.go | 95 +++++++++++++++++++++++++++++++++++++----------- 3 files changed, 106 insertions(+), 41 deletions(-) diff --git a/cmd/common.go b/cmd/common.go index 7f86d26..00fbfa0 100644 --- a/cmd/common.go +++ b/cmd/common.go @@ -449,9 +449,9 @@ func isIPTarget(command, target string) bool { return false } - urlData, err := parseUrlData(target) + targetData, err := parseHTTPTarget(target) - return err == nil && net.ParseIP(urlData.Host) != nil + return err == nil && net.ParseIP(targetData.Host) != nil } func parseTargets(input string) ([]string, error) { diff --git a/cmd/http.go b/cmd/http.go index a0eeeec..106daa9 100644 --- a/cmd/http.go +++ b/cmd/http.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "net" "net/url" "slices" "strconv" @@ -22,6 +23,8 @@ func (r *Root) initHTTP(measurementFlags *pflag.FlagSet, localFlags *pflag.FlagS Long: `The http command sends an HTTP request to a host and can perform a HEAD, GET, or OPTIONS operations, returning detailed performance statistics for each request. Use it to test and assess the performance and availability of your website, API, or other web services. Note that GET responses are limited to 10KB, with anything beyond this cut by the API. +Targets may be hostnames, bare IPv4 or IPv6 addresses, or URLs using http:// or https://. IPv6 addresses inside URLs must be enclosed in brackets. + The CLI tool supports two formats: 1. Full URL: The tool automatically parses the scheme, host, port, domain, path, and query. For example: http https://www.jsdelivr.com:443/package/npm/test?nav=stats @@ -146,7 +149,7 @@ func (r *Root) buildHttpMeasurementRequest(cmd *cobra.Command, target string) (* Timeout: r.ctx.Timeout, InProgressUpdates: !r.ctx.CIMode, } - urlData, err := parseUrlData(target) + targetData, err := parseHTTPTarget(target) if err != nil { return nil, err @@ -165,12 +168,12 @@ func (r *Root) buildHttpMeasurementRequest(cmd *cobra.Command, target string) (* method = "GET" } - opts.Target = urlData.Host + opts.Target = targetData.Host opts.Options = &globalping.MeasurementOptions{ - Protocol: urlData.Protocol, + Protocol: targetData.Protocol, Request: &globalping.RequestOptions{ - Path: overrideOpt(urlData.Path, r.ctx.Path), - Query: overrideOpt(urlData.Query, r.ctx.Query), + Path: overrideOpt(targetData.Path, r.ctx.Path), + Query: overrideOpt(targetData.Query, r.ctx.Query), Host: r.ctx.Host, Headers: headers, Method: method, @@ -188,8 +191,8 @@ func (r *Root) buildHttpMeasurementRequest(cmd *cobra.Command, target string) (* switch { case portFlag != nil && portFlag.Changed: opts.Options.Port = r.ctx.Port - case urlData.HasPort: - opts.Options.Port = urlData.Port + case targetData.HasPort: + opts.Options.Port = targetData.Port case opts.Options.Protocol == "HTTP": opts.Options.Port = 80 default: @@ -215,7 +218,7 @@ func parseHttpHeaders(headerStrings []string) (map[string]string, error) { return h, nil } -type UrlData struct { +type httpTargetData struct { Protocol string Path string Query string @@ -224,9 +227,14 @@ type UrlData struct { HasPort bool } -// parse url data from user text input -func parseUrlData(input string) (*UrlData, error) { - var urlData UrlData +// parseHTTPTarget parses a hostname, IP address, or URL into HTTP target options. +func parseHTTPTarget(input string) (*httpTargetData, error) { + // Preserve bare IP addresses before URL parsing can interpret an IPv6 segment as a port. + if net.ParseIP(input) != nil { + return &httpTargetData{Protocol: "HTTPS", Host: input}, nil + } + + var targetData httpTargetData // add url scheme if missing if !strings.HasPrefix(input, "http://") && !strings.HasPrefix(input, "https://") { @@ -240,11 +248,15 @@ func parseUrlData(input string) (*UrlData, error) { return nil, errors.Wrapf(err, "failed to parse url input") } - urlData.Protocol = strings.ToUpper(u.Scheme) - urlData.Path = u.Path - urlData.Query = u.RawQuery + if strings.Contains(u.Hostname(), ":") && !strings.HasPrefix(u.Host, "[") { + return nil, errors.New("IPv6 URL hosts must be enclosed in brackets") + } + + targetData.Protocol = strings.ToUpper(u.Scheme) + targetData.Path = u.Path + targetData.Query = u.RawQuery - urlData.Host = u.Hostname() + targetData.Host = u.Hostname() p := u.Port() if p != "" { @@ -255,11 +267,11 @@ func parseUrlData(input string) (*UrlData, error) { return nil, errors.Wrapf(err, "failed to parse url port number: %s", p) } - urlData.Port = uint16(port) - urlData.HasPort = true + targetData.Port = uint16(port) + targetData.HasPort = true } - return &urlData, nil + return &targetData, nil } // Helper functions to override flags in command diff --git a/cmd/http_test.go b/cmd/http_test.go index b449e9d..48bd051 100644 --- a/cmd/http_test.go +++ b/cmd/http_test.go @@ -12,6 +12,7 @@ import ( "github.com/jsdelivr/globalping-go" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" ) @@ -205,34 +206,34 @@ func Test_Execute_HTTP_Invalid_Protocol(t *testing.T) { assert.Empty(t, items) } -func Test_ParseUrlData(t *testing.T) { - urlData, err := parseUrlData("https://cdn.jsdelivr.net:8080/npm/react/?query=3") +func Test_ParseHTTPTarget(t *testing.T) { + targetData, err := parseHTTPTarget("https://cdn.jsdelivr.net:8080/npm/react/?query=3") assert.NoError(t, err) - assert.Equal(t, "cdn.jsdelivr.net", urlData.Host) - assert.Equal(t, "/npm/react/", urlData.Path) - assert.Equal(t, "HTTPS", urlData.Protocol) - assert.Equal(t, uint16(8080), urlData.Port) - assert.Equal(t, "query=3", urlData.Query) + assert.Equal(t, "cdn.jsdelivr.net", targetData.Host) + assert.Equal(t, "/npm/react/", targetData.Path) + assert.Equal(t, "HTTPS", targetData.Protocol) + assert.Equal(t, uint16(8080), targetData.Port) + assert.Equal(t, "query=3", targetData.Query) } -func Test_ParseUrlData_NoScheme(t *testing.T) { - urlData, err := parseUrlData("cdn.jsdelivr.net/npm/react/?query=3") +func Test_ParseHTTPTarget_NoScheme(t *testing.T) { + targetData, err := parseHTTPTarget("cdn.jsdelivr.net/npm/react/?query=3") assert.NoError(t, err) - assert.Equal(t, "cdn.jsdelivr.net", urlData.Host) - assert.Equal(t, "/npm/react/", urlData.Path) - assert.Equal(t, "HTTPS", urlData.Protocol) - assert.Equal(t, uint16(0), urlData.Port) - assert.Equal(t, "query=3", urlData.Query) + assert.Equal(t, "cdn.jsdelivr.net", targetData.Host) + assert.Equal(t, "/npm/react/", targetData.Path) + assert.Equal(t, "HTTPS", targetData.Protocol) + assert.Equal(t, uint16(0), targetData.Port) + assert.Equal(t, "query=3", targetData.Query) } -func Test_ParseUrlData_HostOnly(t *testing.T) { - urlData, err := parseUrlData("cdn.jsdelivr.net") +func Test_ParseHTTPTarget_HostOnly(t *testing.T) { + targetData, err := parseHTTPTarget("cdn.jsdelivr.net") assert.NoError(t, err) - assert.Equal(t, "cdn.jsdelivr.net", urlData.Host) - assert.Equal(t, "", urlData.Path) - assert.Equal(t, "HTTPS", urlData.Protocol) - assert.Equal(t, uint16(0), urlData.Port) - assert.Equal(t, "", urlData.Query) + assert.Equal(t, "cdn.jsdelivr.net", targetData.Host) + assert.Equal(t, "", targetData.Path) + assert.Equal(t, "HTTPS", targetData.Protocol) + assert.Equal(t, uint16(0), targetData.Port) + assert.Equal(t, "", targetData.Query) } func Test_ParseHttpHeaders_None(t *testing.T) { @@ -269,6 +270,58 @@ func Test_ParseHttpHeaders_Invalid(t *testing.T) { assert.ErrorContains(t, err, "invalid header") } +func Test_BuildHttpMeasurementRequest_TargetParsing(t *testing.T) { + for _, test := range []struct { + name string + target string + wantTarget string + wantProtocol string + wantPort uint16 + wantPath string + wantQuery string + wantErr bool + }{ + {name: "bare IPv6 decimal segment", target: "2606:4700:4700::1111", wantTarget: "2606:4700:4700::1111", wantProtocol: "HTTPS", wantPort: 443}, + {name: "bare IPv6 hexadecimal segment", target: "2001:db8::abcd", wantTarget: "2001:db8::abcd", wantProtocol: "HTTPS", wantPort: 443}, + {name: "HTTP IPv6 URL requires brackets", target: "http://2606:4700:4700::1111/x", wantErr: true}, + {name: "HTTPS IPv6 URL requires brackets", target: "https://2606:4700:4700::1111/x", wantErr: true}, + } { + t.Run(test.name, func(t *testing.T) { + ctx := createDefaultContext() + ctx.Port = 443 + root := &Root{ctx: ctx} + + measurement, err := root.buildHttpMeasurementRequest(&cobra.Command{}, test.target) + + if test.wantErr { + require.Error(t, err) + assert.Nil(t, measurement) + + return + } + + require.NoError(t, err) + expected := &globalping.MeasurementCreate{ + Type: "http", + Target: test.wantTarget, + Limit: 1, + InProgressUpdates: true, + Options: &globalping.MeasurementOptions{ + Protocol: test.wantProtocol, + Port: test.wantPort, + Request: &globalping.RequestOptions{ + Path: test.wantPath, + Query: test.wantQuery, + Headers: map[string]string{}, + }, + }, + } + + assert.Equal(t, expected, measurement) + }) + } +} + func Test_BuildHttpMeasurementRequest_Full(t *testing.T) { ctx := createDefaultContext() printer := view.NewPrinter(nil, nil, nil) From 3ea0f711df56d5937783e2ad76b24c06228c486f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Kopeck=C3=BD?= Date: Thu, 1 Oct 2026 10:56:28 +0200 Subject: [PATCH 2/3] fix: recognize HTTP2 URL targets --- cmd/common_test.go | 1 + cmd/http.go | 4 ++-- cmd/http_test.go | 13 ++++++++----- cmd/table_test.go | 4 ++-- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/cmd/common_test.go b/cmd/common_test.go index 0dea632..271c070 100644 --- a/cmd/common_test.go +++ b/cmd/common_test.go @@ -109,6 +109,7 @@ func Test_UpdateContext_ComparisonValidatesEveryTargetIPVersion(t *testing.T) { assert.ErrorIs(t, err, ErrTargetIPVersionNotAllowed) assert.True(t, isIPTarget("http", "https://[2001:db8::1]/path")) + assert.True(t, isIPTarget("http", "http2://[2001:db8::1]/path")) } func Test_UpdateContext(t *testing.T) { diff --git a/cmd/http.go b/cmd/http.go index 106daa9..02adbbc 100644 --- a/cmd/http.go +++ b/cmd/http.go @@ -23,7 +23,7 @@ func (r *Root) initHTTP(measurementFlags *pflag.FlagSet, localFlags *pflag.FlagS Long: `The http command sends an HTTP request to a host and can perform a HEAD, GET, or OPTIONS operations, returning detailed performance statistics for each request. Use it to test and assess the performance and availability of your website, API, or other web services. Note that GET responses are limited to 10KB, with anything beyond this cut by the API. -Targets may be hostnames, bare IPv4 or IPv6 addresses, or URLs using http:// or https://. IPv6 addresses inside URLs must be enclosed in brackets. +Targets may be hostnames, bare IPv4 or IPv6 addresses, or URLs using http://, https://, or http2://. IPv6 addresses inside URLs must be enclosed in brackets. The CLI tool supports two formats: 1. Full URL: The tool automatically parses the scheme, host, port, domain, path, and query. For example: @@ -237,7 +237,7 @@ func parseHTTPTarget(input string) (*httpTargetData, error) { var targetData httpTargetData // add url scheme if missing - if !strings.HasPrefix(input, "http://") && !strings.HasPrefix(input, "https://") { + if !strings.HasPrefix(input, "http://") && !strings.HasPrefix(input, "https://") && !strings.HasPrefix(input, "http2://") { input = "https://" + input } diff --git a/cmd/http_test.go b/cmd/http_test.go index 48bd051..9655ccf 100644 --- a/cmd/http_test.go +++ b/cmd/http_test.go @@ -283,8 +283,11 @@ func Test_BuildHttpMeasurementRequest_TargetParsing(t *testing.T) { }{ {name: "bare IPv6 decimal segment", target: "2606:4700:4700::1111", wantTarget: "2606:4700:4700::1111", wantProtocol: "HTTPS", wantPort: 443}, {name: "bare IPv6 hexadecimal segment", target: "2001:db8::abcd", wantTarget: "2001:db8::abcd", wantProtocol: "HTTPS", wantPort: 443}, + {name: "HTTP2 default port", target: "http2://example.com/x?y=1", wantTarget: "example.com", wantProtocol: "HTTP2", wantPort: 443, wantPath: "/x", wantQuery: "y=1"}, + {name: "HTTP2 custom port", target: "http2://example.com:8443/x?y=1", wantTarget: "example.com", wantProtocol: "HTTP2", wantPort: 8443, wantPath: "/x", wantQuery: "y=1"}, {name: "HTTP IPv6 URL requires brackets", target: "http://2606:4700:4700::1111/x", wantErr: true}, {name: "HTTPS IPv6 URL requires brackets", target: "https://2606:4700:4700::1111/x", wantErr: true}, + {name: "HTTP2 IPv6 URL requires brackets", target: "http2://2606:4700:4700::1111/x", wantErr: true}, } { t.Run(test.name, func(t *testing.T) { ctx := createDefaultContext() @@ -455,14 +458,14 @@ func Test_BuildHttpMeasurementRequest_ExplicitFlagsOverrideEachURL(t *testing.T) ctx.Query = "override=1" root := NewRoot(view.NewPrinter(nil, nil, nil), ctx, nil, nil, nil, nil, nil) cmd, _, err := root.Cmd.Find([]string{"http"}) - assert.NoError(t, err) - assert.NoError(t, cmd.Flags().Set("protocol", "HTTP2")) - assert.NoError(t, cmd.Flags().Set("port", "9443")) + require.NoError(t, err) + require.NoError(t, cmd.Flags().Set("protocol", "HTTP2")) + require.NoError(t, cmd.Flags().Set("port", "9443")) - for _, target := range []string{"http://one.example:8080/first?x=1", "https://two.example:8443/second?y=2"} { + for _, target := range []string{"http://one.example:8080/first?x=1", "https://two.example:8443/second?y=2", "http2://three.example/a%2Fb?z=%2C%252C"} { measurement, err := root.buildHttpMeasurementRequest(cmd, target) - assert.NoError(t, err) + require.NoError(t, err) assert.Equal(t, "HTTP2", measurement.Options.Protocol) assert.Equal(t, uint16(9443), measurement.Options.Port) assert.Equal(t, "/override", measurement.Options.Request.Path) diff --git a/cmd/table_test.go b/cmd/table_test.go index bdfc974..5ef0116 100644 --- a/cmd/table_test.go +++ b/cmd/table_test.go @@ -39,10 +39,10 @@ func Test_Execute_ComparisonCreatesBothTargetsForEveryCommand(t *testing.T) { targetArgument := "one.example,two.example" if measurementType == "http" { - targetArgument = "http://one.example:8080/first?x=1,https://two.example:8443/second?y=2" + targetArgument = "http2://one.example:8080/first?x=1,https://two.example:8443/second?y=2" first.Target = "one.example" first.Options = &globalping.MeasurementOptions{ - Protocol: "HTTP", + Protocol: "HTTP2", Port: 8080, Request: &globalping.RequestOptions{ Path: "/first", From bc1d012e093ee9ba4b978ae872f67a2227117df0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Kopeck=C3=BD?= Date: Thu, 1 Oct 2026 10:56:52 +0200 Subject: [PATCH 3/3] fix: preserve escaped paths in HTTP requests --- cmd/http.go | 2 +- cmd/http_test.go | 10 ++++++---- cmd/table_test.go | 10 +++++----- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/cmd/http.go b/cmd/http.go index 02adbbc..cca5935 100644 --- a/cmd/http.go +++ b/cmd/http.go @@ -253,7 +253,7 @@ func parseHTTPTarget(input string) (*httpTargetData, error) { } targetData.Protocol = strings.ToUpper(u.Scheme) - targetData.Path = u.Path + targetData.Path = u.EscapedPath() targetData.Query = u.RawQuery targetData.Host = u.Hostname() diff --git a/cmd/http_test.go b/cmd/http_test.go index 9655ccf..6446f7b 100644 --- a/cmd/http_test.go +++ b/cmd/http_test.go @@ -285,6 +285,8 @@ func Test_BuildHttpMeasurementRequest_TargetParsing(t *testing.T) { {name: "bare IPv6 hexadecimal segment", target: "2001:db8::abcd", wantTarget: "2001:db8::abcd", wantProtocol: "HTTPS", wantPort: 443}, {name: "HTTP2 default port", target: "http2://example.com/x?y=1", wantTarget: "example.com", wantProtocol: "HTTP2", wantPort: 443, wantPath: "/x", wantQuery: "y=1"}, {name: "HTTP2 custom port", target: "http2://example.com:8443/x?y=1", wantTarget: "example.com", wantProtocol: "HTTP2", wantPort: 8443, wantPath: "/x", wantQuery: "y=1"}, + {name: "escaped path delimiters", target: "https://example.com/a%2Fb%3Fc%23d?x=%2F%3F%23", wantTarget: "example.com", wantProtocol: "HTTPS", wantPort: 443, wantPath: "/a%2Fb%3Fc%23d", wantQuery: "x=%2F%3F%23"}, + {name: "encoded commas and percent signs", target: "http2://example.com/a%2Cb%252C?x=%2C&y=%252C&z=a+b", wantTarget: "example.com", wantProtocol: "HTTP2", wantPort: 443, wantPath: "/a%2Cb%252C", wantQuery: "x=%2C&y=%252C&z=a+b"}, {name: "HTTP IPv6 URL requires brackets", target: "http://2606:4700:4700::1111/x", wantErr: true}, {name: "HTTPS IPv6 URL requires brackets", target: "https://2606:4700:4700::1111/x", wantErr: true}, {name: "HTTP2 IPv6 URL requires brackets", target: "http2://2606:4700:4700::1111/x", wantErr: true}, @@ -454,8 +456,8 @@ func Test_BuildHttpMeasurementRequest_ExplicitFlagsOverrideEachURL(t *testing.T) ctx := createDefaultContext() ctx.Protocol = "HTTP2" ctx.Port = 9443 - ctx.Path = "/override" - ctx.Query = "override=1" + ctx.Path = "/override%2F%252C" + ctx.Query = "override=%2C%252C" root := NewRoot(view.NewPrinter(nil, nil, nil), ctx, nil, nil, nil, nil, nil) cmd, _, err := root.Cmd.Find([]string{"http"}) require.NoError(t, err) @@ -468,7 +470,7 @@ func Test_BuildHttpMeasurementRequest_ExplicitFlagsOverrideEachURL(t *testing.T) require.NoError(t, err) assert.Equal(t, "HTTP2", measurement.Options.Protocol) assert.Equal(t, uint16(9443), measurement.Options.Port) - assert.Equal(t, "/override", measurement.Options.Request.Path) - assert.Equal(t, "override=1", measurement.Options.Request.Query) + assert.Equal(t, "/override%2F%252C", measurement.Options.Request.Path) + assert.Equal(t, "override=%2C%252C", measurement.Options.Request.Query) } } diff --git a/cmd/table_test.go b/cmd/table_test.go index 5ef0116..a15ab27 100644 --- a/cmd/table_test.go +++ b/cmd/table_test.go @@ -39,14 +39,14 @@ func Test_Execute_ComparisonCreatesBothTargetsForEveryCommand(t *testing.T) { targetArgument := "one.example,two.example" if measurementType == "http" { - targetArgument = "http2://one.example:8080/first?x=1,https://two.example:8443/second?y=2" + targetArgument = "http2://one.example:8080/a%2Fb%3Fc%23d?x=%2C%252C,https://two.example:8443/second%2C%252C?y=a%2Cb&z=%252C" first.Target = "one.example" first.Options = &globalping.MeasurementOptions{ Protocol: "HTTP2", Port: 8080, Request: &globalping.RequestOptions{ - Path: "/first", - Query: "x=1", + Path: "/a%2Fb%3Fc%23d", + Query: "x=%2C%252C", Headers: map[string]string{}, }, } @@ -55,8 +55,8 @@ func Test_Execute_ComparisonCreatesBothTargetsForEveryCommand(t *testing.T) { Protocol: "HTTPS", Port: 8443, Request: &globalping.RequestOptions{ - Path: "/second", - Query: "y=2", + Path: "/second%2C%252C", + Query: "y=a%2Cb&z=%252C", Headers: map[string]string{}, }, }