From 7d7a117f9a47c5734854c2be51a848bcd49310ef Mon Sep 17 00:00:00 2001 From: Felipe Zipitria Date: Wed, 28 Jan 2026 23:34:15 -0300 Subject: [PATCH 1/2] feat: support follow_redirect Signed-off-by: Felipe Zipitria --- runner/redirect.go | 120 +++++++++++++++ runner/redirect_test.go | 316 ++++++++++++++++++++++++++++++++++++++++ runner/run.go | 15 ++ runner/types.go | 3 + 4 files changed, 454 insertions(+) create mode 100644 runner/redirect.go create mode 100644 runner/redirect_test.go diff --git a/runner/redirect.go b/runner/redirect.go new file mode 100644 index 00000000..3a985b0d --- /dev/null +++ b/runner/redirect.go @@ -0,0 +1,120 @@ +// Copyright 2024 OWASP CRS Project +// SPDX-License-Identifier: Apache-2.0 + +package runner + +import ( + "fmt" + "net/url" + "strconv" + "strings" + + "github.com/coreruleset/go-ftw/v2/ftwhttp" + "github.com/coreruleset/go-ftw/v2/test" + "github.com/rs/zerolog/log" +) + +// RedirectLocation represents a parsed redirect location +type RedirectLocation struct { + Protocol string + Host string + Port int + URI string +} + +// extractRedirectLocation parses the Location header from a redirect response +// and returns the parsed components (protocol, host, port, URI). +// It handles both absolute and relative URLs. +func extractRedirectLocation(response *ftwhttp.Response, baseInput *test.Input) (*RedirectLocation, error) { + if response == nil { + return nil, fmt.Errorf("no previous response available for redirect") + } + + // Check if status code is a redirect (3xx) + statusCode := response.Parsed.StatusCode + if statusCode < 300 || statusCode >= 400 { + return nil, fmt.Errorf("previous response status code %d is not a redirect (3xx)", statusCode) + } + + // Get Location header + location := response.Parsed.Header.Get("Location") + if location == "" { + return nil, fmt.Errorf("previous response is a redirect but has no Location header") + } + + log.Debug().Msgf("Following redirect to: %s", location) + + // Parse the location URL + locationURL, err := url.Parse(location) + if err != nil { + return nil, fmt.Errorf("failed to parse Location header '%s': %w", location, err) + } + + result := &RedirectLocation{} + + // If the URL is relative (no scheme/host), use the base URL from the original request + if !locationURL.IsAbs() { + result.Protocol = baseInput.GetProtocol() + result.Host = baseInput.GetDestAddr() + result.Port = baseInput.GetPort() + + // Handle relative URIs + if strings.HasPrefix(location, "/") { + result.URI = location + } else { + // Relative to current path - merge with base URI + baseURI := baseInput.GetURI() + lastSlash := strings.LastIndex(baseURI, "/") + if lastSlash >= 0 { + result.URI = baseURI[:lastSlash+1] + location + } else { + result.URI = "/" + location + } + } + } else { + // Absolute URL - extract all components + result.Protocol = locationURL.Scheme + result.Host = locationURL.Hostname() + + // Extract port + portStr := locationURL.Port() + if portStr != "" { + port, err := strconv.Atoi(portStr) + if err != nil { + return nil, fmt.Errorf("invalid port in Location header: %s", portStr) + } + result.Port = port + } else { + // Use default port based on scheme + if result.Protocol == "https" { + result.Port = 443 + } else { + result.Port = 80 + } + } + + // Construct URI (path + query + fragment) + result.URI = locationURL.RequestURI() + } + + log.Debug().Msgf("Parsed redirect: protocol=%s, host=%s, port=%d, uri=%s", + result.Protocol, result.Host, result.Port, result.URI) + + return result, nil +} + +// applyRedirectToInput modifies the test input to follow a redirect +func applyRedirectToInput(input *test.Input, redirect *RedirectLocation) { + // Override destination with redirect location + input.Protocol = &redirect.Protocol + input.DestAddr = &redirect.Host + input.Port = &redirect.Port + input.URI = &redirect.URI + + // Update Host header to match the new destination + headers := input.GetHeaders() + headers.Set("Host", redirect.Host) + + log.Debug().Msgf("Applied redirect to input: %s://%s:%d%s", + redirect.Protocol, redirect.Host, redirect.Port, redirect.URI) +} diff --git a/runner/redirect_test.go b/runner/redirect_test.go new file mode 100644 index 00000000..ec5e3d48 --- /dev/null +++ b/runner/redirect_test.go @@ -0,0 +1,316 @@ +// Copyright 2024 OWASP CRS Project +// SPDX-License-Identifier: Apache-2.0 + +package runner + +import ( + "net/http" + "testing" + + schema "github.com/coreruleset/ftw-tests-schema/v2/types" + "github.com/coreruleset/go-ftw/v2/ftwhttp" + "github.com/coreruleset/go-ftw/v2/test" + "github.com/stretchr/testify/suite" +) + +type redirectTestSuite struct { + suite.Suite +} + +func TestRedirectTestSuite(t *testing.T) { + suite.Run(t, new(redirectTestSuite)) +} + +func (s *redirectTestSuite) TestExtractRedirectLocation_AbsoluteURL() { + protocol := "http" + destAddr := "example.com" + port := 80 + uri := "/original" + + baseInput := test.NewInput(&schema.Input{ + Protocol: &protocol, + DestAddr: &destAddr, + Port: &port, + URI: &uri, + }) + + response := &ftwhttp.Response{ + Parsed: http.Response{ + StatusCode: 302, + Header: http.Header{ + "Location": []string{"https://newdomain.com:8443/newpath?query=value"}, + }, + }, + } + + result, err := extractRedirectLocation(response, baseInput) + s.NoError(err) + s.NotNil(result) + s.Equal("https", result.Protocol) + s.Equal("newdomain.com", result.Host) + s.Equal(8443, result.Port) + s.Equal("/newpath?query=value", result.URI) +} + +func (s *redirectTestSuite) TestExtractRedirectLocation_AbsoluteURLWithDefaultPort() { + protocol := "http" + destAddr := "example.com" + port := 80 + uri := "/original" + + baseInput := test.NewInput(&schema.Input{ + Protocol: &protocol, + DestAddr: &destAddr, + Port: &port, + URI: &uri, + }) + + response := &ftwhttp.Response{ + Parsed: http.Response{ + StatusCode: 301, + Header: http.Header{ + "Location": []string{"https://newdomain.com/newpath"}, + }, + }, + } + + result, err := extractRedirectLocation(response, baseInput) + s.NoError(err) + s.NotNil(result) + s.Equal("https", result.Protocol) + s.Equal("newdomain.com", result.Host) + s.Equal(443, result.Port) + s.Equal("/newpath", result.URI) +} + +func (s *redirectTestSuite) TestExtractRedirectLocation_RelativeURLAbsolutePath() { + protocol := "http" + destAddr := "example.com" + port := 8080 + uri := "/original" + + baseInput := test.NewInput(&schema.Input{ + Protocol: &protocol, + DestAddr: &destAddr, + Port: &port, + URI: &uri, + }) + + response := &ftwhttp.Response{ + Parsed: http.Response{ + StatusCode: 302, + Header: http.Header{ + "Location": []string{"/newpath"}, + }, + }, + } + + result, err := extractRedirectLocation(response, baseInput) + s.NoError(err) + s.NotNil(result) + s.Equal("http", result.Protocol) + s.Equal("example.com", result.Host) + s.Equal(8080, result.Port) + s.Equal("/newpath", result.URI) +} + +func (s *redirectTestSuite) TestExtractRedirectLocation_RelativeURLRelativePath() { + protocol := "http" + destAddr := "example.com" + port := 80 + uri := "/path/to/resource" + + baseInput := test.NewInput(&schema.Input{ + Protocol: &protocol, + DestAddr: &destAddr, + Port: &port, + URI: &uri, + }) + + response := &ftwhttp.Response{ + Parsed: http.Response{ + StatusCode: 302, + Header: http.Header{ + "Location": []string{"newresource"}, + }, + }, + } + + result, err := extractRedirectLocation(response, baseInput) + s.NoError(err) + s.NotNil(result) + s.Equal("http", result.Protocol) + s.Equal("example.com", result.Host) + s.Equal(80, result.Port) + s.Equal("/path/to/newresource", result.URI) +} + +func (s *redirectTestSuite) TestExtractRedirectLocation_NoResponse() { + protocol := "http" + destAddr := "example.com" + port := 80 + uri := "/original" + + baseInput := test.NewInput(&schema.Input{ + Protocol: &protocol, + DestAddr: &destAddr, + Port: &port, + URI: &uri, + }) + + result, err := extractRedirectLocation(nil, baseInput) + s.Error(err) + s.Nil(result) + s.Contains(err.Error(), "no previous response available") +} + +func (s *redirectTestSuite) TestExtractRedirectLocation_NotRedirectStatus() { + protocol := "http" + destAddr := "example.com" + port := 80 + uri := "/original" + + baseInput := test.NewInput(&schema.Input{ + Protocol: &protocol, + DestAddr: &destAddr, + Port: &port, + URI: &uri, + }) + + response := &ftwhttp.Response{ + Parsed: http.Response{ + StatusCode: 200, + Header: http.Header{ + "Location": []string{"/newpath"}, + }, + }, + } + + result, err := extractRedirectLocation(response, baseInput) + s.Error(err) + s.Nil(result) + s.Contains(err.Error(), "not a redirect") +} + +func (s *redirectTestSuite) TestExtractRedirectLocation_NoLocationHeader() { + protocol := "http" + destAddr := "example.com" + port := 80 + uri := "/original" + + baseInput := test.NewInput(&schema.Input{ + Protocol: &protocol, + DestAddr: &destAddr, + Port: &port, + URI: &uri, + }) + + response := &ftwhttp.Response{ + Parsed: http.Response{ + StatusCode: 302, + Header: http.Header{}, + }, + } + + result, err := extractRedirectLocation(response, baseInput) + s.Error(err) + s.Nil(result) + s.Contains(err.Error(), "no Location header") +} + +func (s *redirectTestSuite) TestExtractRedirectLocation_HTTPToHTTPS() { + protocol := "http" + destAddr := "example.com" + port := 80 + uri := "/original" + + baseInput := test.NewInput(&schema.Input{ + Protocol: &protocol, + DestAddr: &destAddr, + Port: &port, + URI: &uri, + }) + + response := &ftwhttp.Response{ + Parsed: http.Response{ + StatusCode: 301, + Header: http.Header{ + "Location": []string{"https://example.com/secure"}, + }, + }, + } + + result, err := extractRedirectLocation(response, baseInput) + s.NoError(err) + s.NotNil(result) + s.Equal("https", result.Protocol) + s.Equal("example.com", result.Host) + s.Equal(443, result.Port) + s.Equal("/secure", result.URI) +} + +func (s *redirectTestSuite) TestApplyRedirectToInput() { + protocol := "http" + destAddr := "example.com" + port := 80 + uri := "/original" + + input := test.NewInput(&schema.Input{ + Protocol: &protocol, + DestAddr: &destAddr, + Port: &port, + URI: &uri, + }) + + redirect := &RedirectLocation{ + Protocol: "https", + Host: "newdomain.com", + Port: 8443, + URI: "/newpath", + } + + applyRedirectToInput(input, redirect) + + s.Equal("https", input.GetProtocol()) + s.Equal("newdomain.com", input.GetDestAddr()) + s.Equal(8443, input.GetPort()) + s.Equal("/newpath", input.GetURI()) + + // Check Host header was updated + headers := input.GetHeaders() + hostHeaders := headers.GetAll("Host") + s.Len(hostHeaders, 1) + s.Equal("newdomain.com", hostHeaders[0].Value) +} + +func (s *redirectTestSuite) TestExtractRedirectLocation_Various3xxCodes() { + protocol := "http" + destAddr := "example.com" + port := 80 + uri := "/original" + + baseInput := test.NewInput(&schema.Input{ + Protocol: &protocol, + DestAddr: &destAddr, + Port: &port, + URI: &uri, + }) + + redirectCodes := []int{300, 301, 302, 303, 307, 308} + + for _, code := range redirectCodes { + response := &ftwhttp.Response{ + Parsed: http.Response{ + StatusCode: code, + Header: http.Header{ + "Location": []string{"/redirect"}, + }, + }, + } + + result, err := extractRedirectLocation(response, baseInput) + s.NoError(err, "Failed for status code %d", code) + s.NotNil(result, "Result is nil for status code %d", code) + s.Equal("/redirect", result.URI) + } +} diff --git a/runner/run.go b/runner/run.go index c940ab4d..81baf158 100644 --- a/runner/run.go +++ b/runner/run.go @@ -76,6 +76,9 @@ func RunTest(runContext *TestRunContext, ftwTest *test.FTWTest) error { continue } runContext.StartTest() + // Clear previous response when starting a new test + // (follow_redirect should only work within the same test case) + runContext.LastStageResponse = nil test.ApplyPlatformOverrides(runContext.RunnerConfig, &testCase) // this is just for printing once the next test @@ -137,6 +140,15 @@ func RunStage(runContext *TestRunContext, ftwCheck *FTWCheck, testCase schema.Te return err } + // Handle follow_redirect if enabled + if stage.Input.FollowRedirect != nil && *stage.Input.FollowRedirect { + redirectLocation, err := extractRedirectLocation(runContext.LastStageResponse, testInput) + if err != nil { + return fmt.Errorf("follow_redirect enabled but failed to extract redirect location: %w", err) + } + applyRedirectToInput(testInput, redirectLocation) + } + // Do not even run test if result is overridden. Directly set and display the overridden result. if overridden := overriddenTestResult(ftwCheck, &testCase); overridden != Failed { runContext.Result = overridden @@ -202,6 +214,9 @@ func RunStage(runContext *TestRunContext, ftwCheck *FTWCheck, testCase schema.Te runContext.EndStage(&testCase, testResult, ftwCheck.GetTriggeredRules()) + // Store the response for potential use by follow_redirect in next stage + runContext.LastStageResponse = response + // show the result unless quiet was passed in the command line displayResult(&testCase, runContext, testResult, roundTripTime) diff --git a/runner/types.go b/runner/types.go index 4a65365c..8cd9fc71 100644 --- a/runner/types.go +++ b/runner/types.go @@ -32,6 +32,9 @@ type TestRunContext struct { LogLines *waflog.FTWLogLines CurrentStageDuration time.Duration currentStageStartTime time.Time + // LastStageResponse stores the response from the previous stage, + // used for follow_redirect functionality + LastStageResponse *ftwhttp.Response } func (t *TestRunContext) StartTest() { From ccabc468a10efbf2d50faff898375f2f0fae46be Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 08:34:36 -0300 Subject: [PATCH 2/2] fix: address PR review feedback for follow_redirect implementation (#598) * Initial plan * fix: address PR review comments for follow_redirect - Restrict redirect status codes to 300-303, 307-308 (not all 3xx) - Use url.Parse + ResolveReference for proper URL handling - Include port in Host header for non-default ports - Fix comment about fragments in RequestURI - Store and use previous stage input for relative redirects - Move overriddenTestResult check before follow_redirect - Add zerolog.Disabled to redirect_test.go SetupSuite Co-authored-by: fzipi <3012076+fzipi@users.noreply.github.com> * test: add integration test for follow_redirect Add integration test that validates: - Stage 1 sends request to /redirect-me - Stage 1 receives 302 redirect to /redirected - Stage 2 uses follow_redirect to follow the redirect - Stage 2 sends request to /redirected - Both stages complete successfully Co-authored-by: fzipi <3012076+fzipi@users.noreply.github.com> * test: improve test coverage for follow_redirect - Add test for non-redirect 3xx codes (304, 305, 306) - Remove redundant port != 0 check in applyRedirectToInput - Enhance integration test to validate Host header includes port - Verify Host header is correctly updated after redirect Co-authored-by: fzipi <3012076+fzipi@users.noreply.github.com> * fix: address PR review feedback for follow_redirect implementation Co-authored-by: fzipi <3012076+fzipi@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: fzipi <3012076+fzipi@users.noreply.github.com> --- go.sum | 26 ++----- runner/redirect.go | 95 +++++++++++++------------ runner/redirect_test.go | 42 ++++++++++- runner/run.go | 22 +++--- runner/run_test.go | 68 ++++++++++++++++++ runner/testdata/TestFollowRedirect.yaml | 27 +++++++ runner/types.go | 4 ++ 7 files changed, 208 insertions(+), 76 deletions(-) create mode 100644 runner/testdata/TestFollowRedirect.yaml diff --git a/go.sum b/go.sum index ec17ffad..852ee811 100644 --- a/go.sum +++ b/go.sum @@ -100,8 +100,6 @@ github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= github.com/knadh/koanf/providers/rawbytes v1.0.0 h1:MrKDh/HksJlKJmaZjgs4r8aVBb/zsJyc/8qaSnzcdNI= github.com/knadh/koanf/providers/rawbytes v1.0.0/go.mod h1:KxwYJf1uezTKy6PBtfE+m725NGp4GPVA7XoNTJ/PtLo= -github.com/knadh/koanf/v2 v2.3.0 h1:Qg076dDRFHvqnKG97ZEsi9TAg2/nFTa9hCdcSa1lvlM= -github.com/knadh/koanf/v2 v2.3.0/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= github.com/knadh/koanf/v2 v2.3.2 h1:Ee6tuzQYFwcZXQpc2MiVeC6qHMandf5SMUJJNoFp/c4= github.com/knadh/koanf/v2 v2.3.2/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= @@ -166,8 +164,6 @@ gitlab.com/gitlab-org/api/client-go v1.9.1 h1:tZm+URa36sVy8UCEHQyGGJ8COngV4YqMHp gitlab.com/gitlab-org/api/client-go v1.9.1/go.mod h1:71yTJk1lnHCWcZLvM5kPAXzeJ2fn5GjaoV8gTOPd4ME= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go= -go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U= go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -178,12 +174,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= -golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY= -golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -191,8 +183,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= -golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -203,8 +195,6 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= @@ -232,8 +222,6 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= @@ -245,8 +233,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -256,8 +244,6 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= @@ -268,8 +254,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= -golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/runner/redirect.go b/runner/redirect.go index 3a985b0d..a96a027a 100644 --- a/runner/redirect.go +++ b/runner/redirect.go @@ -7,7 +7,6 @@ import ( "fmt" "net/url" "strconv" - "strings" "github.com/coreruleset/go-ftw/v2/ftwhttp" "github.com/coreruleset/go-ftw/v2/test" @@ -30,10 +29,13 @@ func extractRedirectLocation(response *ftwhttp.Response, baseInput *test.Input) return nil, fmt.Errorf("no previous response available for redirect") } - // Check if status code is a redirect (3xx) + // Check if status code is a redirect statusCode := response.Parsed.StatusCode - if statusCode < 300 || statusCode >= 400 { - return nil, fmt.Errorf("previous response status code %d is not a redirect (3xx)", statusCode) + switch statusCode { + case 300, 301, 302, 303, 307, 308: + // valid redirect status codes + default: + return nil, fmt.Errorf("previous response status code %d is not a redirect", statusCode) } // Get Location header @@ -52,51 +54,48 @@ func extractRedirectLocation(response *ftwhttp.Response, baseInput *test.Input) result := &RedirectLocation{} - // If the URL is relative (no scheme/host), use the base URL from the original request - if !locationURL.IsAbs() { - result.Protocol = baseInput.GetProtocol() - result.Host = baseInput.GetDestAddr() - result.Port = baseInput.GetPort() + // Build base URL from the previous request for resolving relative redirects + baseURL := &url.URL{ + Scheme: baseInput.GetProtocol(), + Host: baseInput.GetDestAddr(), + Path: baseInput.GetURI(), + } + + // Add port to host if it's not a default port + port := baseInput.GetPort() + isDefaultPort := (baseURL.Scheme == "https" && port == 443) || + (baseURL.Scheme == "http" && port == 80) + if !isDefaultPort { + baseURL.Host = fmt.Sprintf("%s:%d", baseURL.Host, port) + } - // Handle relative URIs - if strings.HasPrefix(location, "/") { - result.URI = location - } else { - // Relative to current path - merge with base URI - baseURI := baseInput.GetURI() - lastSlash := strings.LastIndex(baseURI, "/") - if lastSlash >= 0 { - result.URI = baseURI[:lastSlash+1] + location - } else { - result.URI = "/" + location - } + // Resolve the location URL against the base URL + resolvedURL := baseURL.ResolveReference(locationURL) + + // Extract components from resolved URL + result.Protocol = resolvedURL.Scheme + result.Host = resolvedURL.Hostname() + + // Extract port + portStr := resolvedURL.Port() + if portStr != "" { + parsedPort, err := strconv.Atoi(portStr) + if err != nil { + return nil, fmt.Errorf("invalid port in Location header: %s", portStr) } + result.Port = parsedPort } else { - // Absolute URL - extract all components - result.Protocol = locationURL.Scheme - result.Host = locationURL.Hostname() - - // Extract port - portStr := locationURL.Port() - if portStr != "" { - port, err := strconv.Atoi(portStr) - if err != nil { - return nil, fmt.Errorf("invalid port in Location header: %s", portStr) - } - result.Port = port + // Use default port based on scheme + if result.Protocol == "https" { + result.Port = 443 } else { - // Use default port based on scheme - if result.Protocol == "https" { - result.Port = 443 - } else { - result.Port = 80 - } + result.Port = 80 } - - // Construct URI (path + query + fragment) - result.URI = locationURL.RequestURI() } + // Construct URI (path + query); fragments are not included in RequestURI + result.URI = resolvedURL.RequestURI() + log.Debug().Msgf("Parsed redirect: protocol=%s, host=%s, port=%d, uri=%s", result.Protocol, result.Host, result.Port, result.URI) @@ -111,9 +110,17 @@ func applyRedirectToInput(input *test.Input, redirect *RedirectLocation) { input.Port = &redirect.Port input.URI = &redirect.URI - // Update Host header to match the new destination + // Update Host header to match the new destination, including non-default ports headers := input.GetHeaders() - headers.Set("Host", redirect.Host) + + hostHeader := redirect.Host + isDefaultPort := (redirect.Protocol == "https" && redirect.Port == 443) || + (redirect.Protocol == "http" && redirect.Port == 80) + if !isDefaultPort { + hostHeader = fmt.Sprintf("%s:%d", redirect.Host, redirect.Port) + } + + headers.Set("Host", hostHeader) log.Debug().Msgf("Applied redirect to input: %s://%s:%d%s", redirect.Protocol, redirect.Host, redirect.Port, redirect.URI) diff --git a/runner/redirect_test.go b/runner/redirect_test.go index ec5e3d48..52560b6b 100644 --- a/runner/redirect_test.go +++ b/runner/redirect_test.go @@ -10,6 +10,7 @@ import ( schema "github.com/coreruleset/ftw-tests-schema/v2/types" "github.com/coreruleset/go-ftw/v2/ftwhttp" "github.com/coreruleset/go-ftw/v2/test" + "github.com/rs/zerolog" "github.com/stretchr/testify/suite" ) @@ -17,6 +18,10 @@ type redirectTestSuite struct { suite.Suite } +func (s *redirectTestSuite) SetupSuite() { + zerolog.SetGlobalLevel(zerolog.Disabled) +} + func TestRedirectTestSuite(t *testing.T) { suite.Run(t, new(redirectTestSuite)) } @@ -276,11 +281,11 @@ func (s *redirectTestSuite) TestApplyRedirectToInput() { s.Equal(8443, input.GetPort()) s.Equal("/newpath", input.GetURI()) - // Check Host header was updated + // Check Host header was updated (should include port for non-default ports) headers := input.GetHeaders() hostHeaders := headers.GetAll("Host") s.Len(hostHeaders, 1) - s.Equal("newdomain.com", hostHeaders[0].Value) + s.Equal("newdomain.com:8443", hostHeaders[0].Value) } func (s *redirectTestSuite) TestExtractRedirectLocation_Various3xxCodes() { @@ -314,3 +319,36 @@ func (s *redirectTestSuite) TestExtractRedirectLocation_Various3xxCodes() { s.Equal("/redirect", result.URI) } } + +func (s *redirectTestSuite) TestExtractRedirectLocation_NonRedirect3xxCodes() { + protocol := "http" + destAddr := "example.com" + port := 80 + uri := "/original" + + baseInput := test.NewInput(&schema.Input{ + Protocol: &protocol, + DestAddr: &destAddr, + Port: &port, + URI: &uri, + }) + + // Test non-redirect 3xx codes that should be rejected + nonRedirectCodes := []int{304, 305, 306} + + for _, code := range nonRedirectCodes { + response := &ftwhttp.Response{ + Parsed: http.Response{ + StatusCode: code, + Header: http.Header{ + "Location": []string{"/somewhere"}, + }, + }, + } + + result, err := extractRedirectLocation(response, baseInput) + s.Error(err, "Should reject status code %d", code) + s.Nil(result, "Result should be nil for status code %d", code) + s.Contains(err.Error(), "not a redirect", "Error message should indicate it's not a redirect for code %d", code) + } +} diff --git a/runner/run.go b/runner/run.go index 81baf158..397aa7d7 100644 --- a/runner/run.go +++ b/runner/run.go @@ -76,9 +76,10 @@ func RunTest(runContext *TestRunContext, ftwTest *test.FTWTest) error { continue } runContext.StartTest() - // Clear previous response when starting a new test + // Clear previous response and input when starting a new test // (follow_redirect should only work within the same test case) runContext.LastStageResponse = nil + runContext.LastStageInput = nil test.ApplyPlatformOverrides(runContext.RunnerConfig, &testCase) // this is just for printing once the next test @@ -140,22 +141,22 @@ func RunStage(runContext *TestRunContext, ftwCheck *FTWCheck, testCase schema.Te return err } + // Do not even run test if result is overridden. Directly set and display the overridden result. + if overridden := overriddenTestResult(ftwCheck, &testCase); overridden != Failed { + runContext.Result = overridden + displayResult(&testCase, runContext, overridden, time.Duration(0)) + return nil + } + // Handle follow_redirect if enabled if stage.Input.FollowRedirect != nil && *stage.Input.FollowRedirect { - redirectLocation, err := extractRedirectLocation(runContext.LastStageResponse, testInput) + redirectLocation, err := extractRedirectLocation(runContext.LastStageResponse, runContext.LastStageInput) if err != nil { return fmt.Errorf("follow_redirect enabled but failed to extract redirect location: %w", err) } applyRedirectToInput(testInput, redirectLocation) } - // Do not even run test if result is overridden. Directly set and display the overridden result. - if overridden := overriddenTestResult(ftwCheck, &testCase); overridden != Failed { - runContext.Result = overridden - displayResult(&testCase, runContext, overridden, time.Duration(0)) - return nil - } - // Destination is needed for a request dest := &ftwhttp.Destination{ DestAddr: testInput.GetDestAddr(), @@ -214,8 +215,9 @@ func RunStage(runContext *TestRunContext, ftwCheck *FTWCheck, testCase schema.Te runContext.EndStage(&testCase, testResult, ftwCheck.GetTriggeredRules()) - // Store the response for potential use by follow_redirect in next stage + // Store the response and input for potential use by follow_redirect in next stage runContext.LastStageResponse = response + runContext.LastStageInput = testInput // show the result unless quiet was passed in the command line displayResult(&testCase, runContext, testResult, roundTripTime) diff --git a/runner/run_test.go b/runner/run_test.go index 9696ff03..5c5e6ee9 100644 --- a/runner/run_test.go +++ b/runner/run_test.go @@ -12,6 +12,7 @@ import ( "os" "regexp" "strconv" + "sync" "testing" "text/template" @@ -339,6 +340,73 @@ func (s *runTestSuite) TestOverrideRun() { s.LessOrEqual(0, res.Stats.TotalFailed(), "Oops, test run failed!") } +func (s *runTestSuite) TestFollowRedirect() { + // Track which URIs were requested to validate redirect behavior + var requestedURIs []string + var requestedHosts []string + var mu sync.Mutex + + // Custom handler that returns a redirect on first request + handler := func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requestedURIs = append(requestedURIs, r.RequestURI) + requestedHosts = append(requestedHosts, r.Host) + mu.Unlock() + + // Don't track marker requests + if r.Header.Get(s.cfg.LogMarkerHeaderName) != "" { + s.writeMarkerOrMessageToTestServerLog(logText, r) + w.WriteHeader(http.StatusOK) + return + } + + if r.RequestURI == "/redirect-me" { + // Stage 1: Return relative redirect to /redirected + w.Header().Set("Location", "/redirected") + w.WriteHeader(http.StatusFound) + _, _ = w.Write([]byte("Redirecting...")) + } else if r.RequestURI == "/redirected" { + // Stage 2: Return success + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("Success after redirect")) + } else { + // Unexpected URI + w.WriteHeader(http.StatusNotFound) + } + + s.writeMarkerOrMessageToTestServerLog(logText, r) + } + + s.ts.Config.Handler = http.HandlerFunc(handler) + + s.runnerConfig.Output = output.Quiet + res, err := Run(s.runnerConfig, s.ftwTests, s.out) + s.Require().NoError(err) + s.Equal(0, res.Stats.TotalFailed(), "Follow redirect test should pass") + + // Verify that both URIs were requested (excluding marker requests) + mu.Lock() + actualRequests := []string{} + actualHosts := []string{} + for i, uri := range requestedURIs { + if uri != "/status/200" { // Skip marker requests + actualRequests = append(actualRequests, uri) + actualHosts = append(actualHosts, requestedHosts[i]) + } + } + mu.Unlock() + + s.Require().Len(actualRequests, 2, "Should have made 2 non-marker requests") + s.Equal("/redirect-me", actualRequests[0], "First request should be to /redirect-me") + s.Equal("/redirected", actualRequests[1], "Second request should be to /redirected (redirect target)") + + // Verify Host headers + s.Require().Len(actualHosts, 2, "Should have captured 2 Host headers") + // First request uses Host from test yaml (just IP, no port) + // Second request (after redirect) should include port for non-default port + s.Contains(actualHosts[1], ":", "Host header should include port after redirect for non-default port") +} + func (s *runTestSuite) TestBrokenOverrideRun() { // the test should succeed, despite the unknown override property res, err := Run(s.runnerConfig, s.ftwTests, s.out) diff --git a/runner/testdata/TestFollowRedirect.yaml b/runner/testdata/TestFollowRedirect.yaml new file mode 100644 index 00000000..16e3601f --- /dev/null +++ b/runner/testdata/TestFollowRedirect.yaml @@ -0,0 +1,27 @@ +--- +meta: + author: "tester" + description: "Test follow_redirect functionality" +tests: + - test_id: 1 + description: "Test redirect following between stages" + stages: + # Stage 1: Initial request that returns a redirect + - input: + dest_addr: "{{ .TestAddr }}" + port: {{ .TestPort }} + uri: "/redirect-me" + headers: + User-Agent: "go-ftw test" + Host: "{{ .TestAddr }}" + output: + expect_error: False + status: 302 + # Stage 2: Follow the redirect from stage 1 + - input: + follow_redirect: true + headers: + User-Agent: "go-ftw test" + output: + expect_error: False + status: 200 diff --git a/runner/types.go b/runner/types.go index 8cd9fc71..43f72446 100644 --- a/runner/types.go +++ b/runner/types.go @@ -11,6 +11,7 @@ import ( "github.com/coreruleset/go-ftw/v2/config" "github.com/coreruleset/go-ftw/v2/ftwhttp" "github.com/coreruleset/go-ftw/v2/output" + "github.com/coreruleset/go-ftw/v2/test" "github.com/coreruleset/go-ftw/v2/waflog" ) @@ -35,6 +36,9 @@ type TestRunContext struct { // LastStageResponse stores the response from the previous stage, // used for follow_redirect functionality LastStageResponse *ftwhttp.Response + // LastStageInput stores the input from the previous stage, + // used as base for resolving relative redirects + LastStageInput *test.Input } func (t *TestRunContext) StartTest() {