diff --git a/pkg/client/client.go b/pkg/client/client.go index bea9caa5..dbda558e 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -187,8 +187,10 @@ func New(options *Options) (*Client, error) { return nil, errkit.Wrap(err, "failed to decode public key") } client.pubKey = pubKey - if serverURL, err := url.Parse(options.SessionInfo.ServerURL); err == nil { + registrationServerURL := options.SessionInfo.ServerURL + if serverURL, err := parseServerURL(options.SessionInfo.ServerURL); err == nil { client.serverURL = serverURL + registrationServerURL = serverURL.String() } // attempts to re-register - server will reject is already existing registrationRequest, err := encodeRegistrationRequest(options.SessionInfo.PublicKey, options.SessionInfo.SecretKey, options.SessionInfo.CorrelationID) @@ -196,7 +198,7 @@ func New(options *Options) (*Client, error) { return nil, err } // silently fails to re-register if the session is still alive - _ = client.performRegistration(options.SessionInfo.ServerURL, registrationRequest) + _ = client.performRegistration(registrationServerURL, registrationRequest) } else { payload, err := client.initializeRSAKeys() if err != nil { @@ -327,11 +329,11 @@ func (c *Client) parseServerURLs(serverURL string, payload []byte) error { } values := strings.Split(serverURL, ",") - registerFunc := func(idx int, value string) error { + registerFunc := func(_ int, value string) error { if !stringsutil.HasPrefixAny(value, "http://", "https://") { value = fmt.Sprintf("https://%s", value) } - parsed, err := url.Parse(value) + parsed, err := parseServerURL(value) if err != nil { return errkit.Wrap(err, "could not parse server URL") } @@ -369,6 +371,20 @@ func (c *Client) parseServerURLs(serverURL string, payload []byte) error { return nil } +func parseServerURL(value string) (*url.URL, error) { + parsed, err := url.Parse(value) + if err != nil { + return nil, err + } + + for strings.HasSuffix(parsed.EscapedPath(), "/") { + parsed.Path = strings.TrimSuffix(parsed.Path, "/") + parsed.RawPath = strings.TrimSuffix(parsed.RawPath, "/") + } + + return parsed, nil +} + // InteractionCallback is a callback function for a reported interaction type InteractionCallback func(*server.Interaction) diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go new file mode 100644 index 00000000..27c708ac --- /dev/null +++ b/pkg/client/client_test.go @@ -0,0 +1,96 @@ +package client + +import ( + "crypto/x509" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/projectdiscovery/interactsh/pkg/options" + "github.com/projectdiscovery/interactsh/pkg/server" + "github.com/projectdiscovery/retryablehttp-go" + "github.com/stretchr/testify/require" +) + +func TestNewAcceptsServerURLWithOptionalTrailingSlash(t *testing.T) { + var registrationCount atomic.Int64 + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.EscapedPath() { + case "//register": + // Some deployments canonicalize this path with a redirect that drops the POST body. + http.Redirect(w, r, "/register", http.StatusMovedPermanently) + case "/register", "/base%2F/register": + request := &server.RegisterRequest{} + if err := json.NewDecoder(r.Body).Decode(request); err != nil { + http.Error(w, fmt.Sprintf(`{"error":"could not decode json body: %s"}`, err), http.StatusBadRequest) + return + } + registrationCount.Add(1) + _, _ = w.Write([]byte(`{"message":"registration successful"}`)) + case "/poll": + _, _ = w.Write([]byte(`{"data":[],"extra":[],"aes_key":""}`)) + case "/deregister", "/base%2F/deregister": + w.WriteHeader(http.StatusOK) + default: + http.NotFound(w, r) + } + }) + + testServer := httptest.NewTLSServer(handler) + t.Cleanup(testServer.Close) + + httpClient := retryablehttp.NewClient(retryablehttp.DefaultOptionsSpraying) + httpClient.HTTPClient = testServer.Client() + + for _, test := range []struct { + name string + suffix string + }{ + {name: "without trailing slash"}, + {name: "with trailing slash", suffix: "/"}, + {name: "with percent-encoded slash", suffix: "/base%2F/"}, + } { + t.Run(test.name, func(t *testing.T) { + interactshClient, err := New(&Options{ + ServerURL: testServer.URL + test.suffix, + DisableHTTPFallback: true, + HTTPClient: httpClient, + }) + require.NoError(t, err) + require.NoError(t, interactshClient.Close()) + }) + } + + t.Run("resumed session with trailing slash", func(t *testing.T) { + originalClient, err := New(&Options{ + ServerURL: testServer.URL, + DisableHTTPFallback: true, + HTTPClient: httpClient, + }) + require.NoError(t, err) + + publicKey, err := encodePublicKey(originalClient.pubKey) + require.NoError(t, err) + sessionInfo := &options.SessionInfo{ + ServerURL: testServer.URL + "/", + PrivateKey: string(x509.MarshalPKCS1PrivateKey(originalClient.privKey)), + CorrelationID: originalClient.correlationID, + SecretKey: originalClient.secretKey, + PublicKey: publicKey, + } + require.NoError(t, originalClient.Close()) + + registrationsBeforeResume := registrationCount.Load() + resumedClient, err := New(&Options{ + SessionInfo: sessionInfo, + HTTPClient: httpClient, + }) + require.NoError(t, err) + require.Equal(t, registrationsBeforeResume+1, registrationCount.Load()) + require.NoError(t, resumedClient.getInteractions(func(*server.Interaction) {})) + require.NoError(t, resumedClient.Close()) + }) +}