Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-pages.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorNaman Jagdish Gala <ngala@gitlab.com>2024-01-08 06:17:10 +0300
committerJaime Martinez <jmartinez@gitlab.com>2024-01-08 06:17:10 +0300
commit09f456311d4a937e47a709561b992abaca3fe501 (patch)
treebe6c69a7859813f9b361b8f9bf0b21a1d97d685f /internal
parente3e0a2a0f50c363496786c7de2fa5fd966881264 (diff)
Add support for domain level redirects
Diffstat (limited to 'internal')
-rw-r--r--internal/feature/feature.go28
-rw-r--r--internal/redirects/matching.go107
-rw-r--r--internal/redirects/matching_test.go355
-rw-r--r--internal/redirects/redirects.go5
-rw-r--r--internal/redirects/utils.go36
-rw-r--r--internal/redirects/utils_test.go83
-rw-r--r--internal/redirects/validations.go108
-rw-r--r--internal/redirects/validations_test.go325
-rw-r--r--internal/serving/disk/helpers.go10
-rw-r--r--internal/serving/disk/reader.go7
10 files changed, 988 insertions, 76 deletions
diff --git a/internal/feature/feature.go b/internal/feature/feature.go
index daf5f7c9..b01e9c7a 100644
--- a/internal/feature/feature.go
+++ b/internal/feature/feature.go
@@ -7,6 +7,19 @@ type Feature struct {
defaultEnabled bool
}
+// Enabled reads the environment variable responsible for the feature flag
+// if FF is disabled by default, the environment variable needs to be "true" to explicitly enable it
+// if FF is enabled by default, variable needs to be "false" to explicitly disable it
+func (f Feature) Enabled() bool {
+ env := os.Getenv(f.EnvVariable)
+
+ if f.defaultEnabled {
+ return env != "false"
+ }
+
+ return env == "true"
+}
+
// RedirectsPlaceholders enables support for placeholders in redirects file
// TODO: remove https://gitlab.com/gitlab-org/gitlab-pages/-/issues/620
var RedirectsPlaceholders = Feature{
@@ -25,15 +38,8 @@ var ProjectPrefixCookiePath = Feature{
defaultEnabled: false,
}
-// Enabled reads the environment variable responsible for the feature flag
-// if FF is disabled by default, the environment variable needs to be "true" to explicitly enable it
-// if FF is enabled by default, variable needs to be "false" to explicitly disable it
-func (f Feature) Enabled() bool {
- env := os.Getenv(f.EnvVariable)
-
- if f.defaultEnabled {
- return env != "false"
- }
-
- return env == "true"
+// DomainRedirects enables support for domain level redirects
+var DomainRedirects = Feature{
+ EnvVariable: "FF_ENABLE_DOMAIN_REDIRECT",
+ defaultEnabled: false,
}
diff --git a/internal/redirects/matching.go b/internal/redirects/matching.go
index d52863ee..e5cb8a48 100644
--- a/internal/redirects/matching.go
+++ b/internal/redirects/matching.go
@@ -2,6 +2,7 @@ package redirects
import (
"fmt"
+ "net/url"
"regexp"
"strings"
@@ -12,11 +13,12 @@ import (
)
var (
- regexMultipleSlashes = regexp.MustCompile(`//+`)
+ regexMultipleSlashes = regexp.MustCompile(`([^:])//+`)
regexPlaceholderOrSplats = regexp.MustCompile(`(?i)\*|:[a-z]+`)
)
// matchesRule returns `true` if the rule's "from" pattern matches the requested URL.
+// This internally calls matchesRuleWithPlaceholderOrSplats to match rules.
//
// For example, given a "from" URL like this:
//
@@ -30,37 +32,56 @@ var (
// If the first return value is `true`, the second return value is the path that this
// rule should redirect/rewrite to. This path is effectively the rule's "to" path that
// has been templated with all the placeholders (if any) from the originally requested URL.
-//
-// TODO: Likely these should include host comparison once we have domain-level redirects
-// https://gitlab.com/gitlab-org/gitlab-pages/-/issues/601
-func matchesRule(rule *netlifyRedirects.Rule, path string) (bool, string) {
+func matchesRule(rule *netlifyRedirects.Rule, originalURL *url.URL) (bool, string) {
+ hostMatches, fromPath := matchHost(originalURL, rule.From)
+
+ if !hostMatches {
+ return false, ""
+ }
+
+ path := originalURL.Path
+
+ if !feature.DomainRedirects.Enabled() && isDomainURL(rule.To) {
+ return false, ""
+ }
+
// If the requested URL exactly matches this rule's "from" path,
// exit early and return the rule's "to" path to avoid building
// and compiling the regex below.
// However, only do this if there's nothing to template in the "to" path,
- // to avoid redirect/rewriting to a url with a literal `:placeholder` in it.
- if normalizePath(rule.From) == normalizePath(path) && !regexPlaceholderOrSplats.MatchString(rule.To) {
+ // to avoid redirect/rewriting to a originalURL with a literal `:placeholder` in it.
+ if normalizePath(fromPath) == normalizePath(path) && !regexPlaceholderOrSplats.MatchString(rule.To) {
return true, rule.To
}
+ return matchesRuleWithPlaceholderOrSplats(path, fromPath, rule.To, rule.Status)
+}
+
+// matchesRuleWithPlaceholderOrSplats returns `true` if the rule's "from" pattern matches the requested URL.
+// This is specifically for Placeholders and Splats matching
+//
+// For example, given a "from" URL like this:
+//
+// /a/*/originalURL/with/:placeholders
+//
+// this function would match URLs like this:
+//
+// /a/nice/originalURL/with/text
+// /a/super/extra/nice/originalURL/with/matches
+//
+// If the first return value is `true`, the second return value is the path that this
+// rule should redirect/rewrite to. This path is effectively the rule's "to" path that
+// has been templated with all the placeholders (if any) from the originally requested URL.
+func matchesRuleWithPlaceholderOrSplats(requestPath string, fromPath string, toPath string, status int) (bool, string) {
// Any logic beyond this point handles placeholders and splats.
// If the FF_ENABLE_PLACEHOLDERS feature flag isn't enabled, exit now.
if !feature.RedirectsPlaceholders.Enabled() {
return false, ""
}
- var regexSegments []string
- for _, segment := range strings.Split(rule.From, "/") {
- if segment == "" {
- continue
- } else if regexSplat.MatchString(segment) {
- regexSegments = append(regexSegments, `/*(?P<splat>.*)/*`)
- } else if regexPlaceholder.MatchString(segment) {
- segmentName := strings.Replace(segment, ":", "", 1)
- regexSegments = append(regexSegments, fmt.Sprintf(`/+(?P<%s>[^/]+)`, segmentName))
- } else {
- regexSegments = append(regexSegments, "/+"+regexp.QuoteMeta(segment))
- }
+ regexSegments := convertToRegexSegments(fromPath)
+ if len(regexSegments) == 0 {
+ return false, ""
}
fromRegexString := `(?i)^` + strings.Join(regexSegments, "") + `/*$`
@@ -68,40 +89,64 @@ func matchesRule(rule *netlifyRedirects.Rule, path string) (bool, string) {
if err != nil {
log.WithFields(log.Fields{
"fromRegexString": fromRegexString,
- "rule.From": rule.From,
- "rule.To": rule.To,
- "rule.Status": rule.Status,
- "path": path,
+ "rule.From": fromPath,
+ "rule.To": toPath,
+ "rule.Status": status,
+ "path": requestPath,
}).WithError(err).Warnf("matchesRule generated an invalid regex: %q", fromRegexString)
return false, ""
}
- template := regexPlaceholderReplacement.ReplaceAllString(rule.To, `${$placeholder}`)
- submatchIndex := fromRegex.FindStringSubmatchIndex(path)
+ template := regexPlaceholderReplacement.ReplaceAllString(toPath, `${$placeholder}`)
+ subMatchIndex := fromRegex.FindStringSubmatchIndex(requestPath)
- if submatchIndex == nil {
+ if subMatchIndex == nil {
return false, ""
}
- templatedToPath := []byte{}
- templatedToPath = fromRegex.ExpandString(templatedToPath, template, path, submatchIndex)
+ var templatedToPath []byte
+ templatedToPath = fromRegex.ExpandString(templatedToPath, template, requestPath, subMatchIndex)
// Some replacements result in subsequent slashes. For example, a rule with a "to"
// like `foo/:splat/bar` will result in a path like `foo//bar` if the splat
// character matches nothing. To avoid this, replace all instances
// of multiple subsequent forward slashes with a single forward slash.
- templatedToPath = regexMultipleSlashes.ReplaceAll(templatedToPath, []byte("/"))
+ // The regex captures any character except a colon ([^:]) before the double slashes
+ // and includes it in the replacement.
+ templatedToPath = regexMultipleSlashes.ReplaceAll(templatedToPath, []byte("$1/"))
return true, string(templatedToPath)
}
+// convertToRegexSegments converts the path string to an array of regex segments
+// It replaces placeholders with named capture groups and splat characters with a wildcard regex
+// This allows matching the path segments to the request path and extracting matched placeholder values
+func convertToRegexSegments(path string) []string {
+ var regexSegments []string
+
+ for _, segment := range strings.Split(path, "/") {
+ if segment == "" {
+ continue
+ } else if regexSplat.MatchString(segment) {
+ regexSegments = append(regexSegments, `/*(?P<splat>.*)/*`)
+ } else if regexPlaceholder.MatchString(segment) {
+ segmentName := strings.Replace(segment, ":", "", 1)
+ regexSegments = append(regexSegments, fmt.Sprintf(`/+(?P<%s>[^/]+)`, segmentName))
+ } else {
+ regexSegments = append(regexSegments, "/+"+regexp.QuoteMeta(segment))
+ }
+ }
+
+ return regexSegments
+}
+
// `match` returns:
// 1. The first valid redirect or rewrite rule that matches the requested URL
// 2. The URL to redirect/rewrite to
//
// If no rule matches, this function returns `nil` and an empty string
-func (r *Redirects) match(path string) (*netlifyRedirects.Rule, string) {
+func (r *Redirects) match(originalURL *url.URL) (*netlifyRedirects.Rule, string) {
for i := range r.rules {
if i >= cfg.MaxRuleCount {
// do not process any more rules
@@ -116,7 +161,7 @@ func (r *Redirects) match(path string) (*netlifyRedirects.Rule, string) {
continue
}
- if isMatch, path := matchesRule(&rule, path); isMatch {
+ if isMatch, path := matchesRule(&rule, originalURL); isMatch {
return &rule, path
}
}
diff --git a/internal/redirects/matching_test.go b/internal/redirects/matching_test.go
index 23815b97..3938b680 100644
--- a/internal/redirects/matching_test.go
+++ b/internal/redirects/matching_test.go
@@ -1,6 +1,7 @@
package redirects
import (
+ "net/url"
"testing"
"github.com/stretchr/testify/require"
@@ -56,7 +57,7 @@ var testsWithoutPlaceholders = map[string]testCaseData{
},
}
-func Test_matchesRule(t *testing.T) {
+func testMatchesRule(t *testing.T) {
t.Setenv(feature.RedirectsPlaceholders.EnvVariable, "true")
tests := mergeTestSuites(testsWithoutPlaceholders, map[string]testCaseData{
@@ -68,11 +69,12 @@ func Test_matchesRule(t *testing.T) {
expectMatch: true,
expectedPath: "/bar/",
},
+ // Since we are treating path as an entire path, so // is considered domain name
"multiple_leading_slashes": {
rule: "/foo/ /bar/",
path: "//foo",
- expectMatch: true,
- expectedPath: "/bar/",
+ expectMatch: false,
+ expectedPath: "",
},
"multiple_slashes_in_middle": {
rule: "/foo/bar /baz/",
@@ -81,6 +83,13 @@ func Test_matchesRule(t *testing.T) {
expectedPath: "/baz/",
},
+ "schema_host_from_url": {
+ rule: "http://pages.example.io/foo /baz/",
+ path: "http://pages.example.io/foo",
+ expectMatch: true,
+ expectedPath: "/baz/",
+ },
+
"splat_match": {
rule: "/foo/*/bar /foo/:splat/qux",
path: "/foo/baz/bar",
@@ -153,12 +162,6 @@ func Test_matchesRule(t *testing.T) {
expectMatch: true,
expectedPath: "/qux/foo/bar",
},
- "multiple_splats": {
- rule: "/foo/*/bar/*/baz /qux/:splat",
- path: "/foo/hello/bar/world/baz",
- expectMatch: true,
- expectedPath: "/qux/hello",
- },
"duplicate_splat_replacements": {
rule: "/foo/* /qux/:splat/:splat",
path: "/foo/hello",
@@ -301,13 +304,25 @@ func Test_matchesRule(t *testing.T) {
rules, err := netlifyRedirects.ParseString(tt.rule)
require.NoError(t, err)
- isMatch, path := matchesRule(&rules[0], tt.path)
+ parsedURL, err := url.Parse(tt.path)
+ require.NoError(t, err)
+
+ isMatch, path := matchesRule(&rules[0], parsedURL)
require.Equal(t, tt.expectMatch, isMatch)
require.Equal(t, tt.expectedPath, path)
})
}
}
+func Test_matchesRule(t *testing.T) {
+ testMatchesRule(t)
+}
+
+func Test_matchesNonDomainRule_DomainRedirects_enabled(t *testing.T) {
+ t.Setenv(feature.DomainRedirects.EnvVariable, "true")
+ testMatchesRule(t)
+}
+
// Tests matching behavior when the `FF_ENABLE_PLACEHOLDERS`
// feature flag is not enabled. These tests can be removed when the
// `FF_ENABLE_PLACEHOLDERS` flag is removed.
@@ -343,7 +358,325 @@ func Test_matchesRule_NoPlaceholders(t *testing.T) {
rules, err := netlifyRedirects.ParseString(tt.rule)
require.NoError(t, err)
- isMatch, path := matchesRule(&rules[0], tt.path)
+ parsedURL, err := url.Parse(tt.path)
+ require.NoError(t, err)
+
+ isMatch, path := matchesRule(&rules[0], parsedURL)
+ require.Equal(t, tt.expectMatch, isMatch)
+ require.Equal(t, tt.expectedPath, path)
+ })
+ }
+}
+
+// Tests matching behavior when the `FF_ENABLE_DOMAIN_REDIRECT`
+// feature flag is enabled.
+func Test_matchesDomainRule(t *testing.T) {
+ t.Setenv(feature.RedirectsPlaceholders.EnvVariable, "true")
+ t.Setenv(feature.DomainRedirects.EnvVariable, "true")
+
+ tests := map[string]testCaseData{
+ "exact_path_match": {
+ rule: "/foo/ http://test.example.io/bar/",
+ path: "http://pages.example.io/foo/",
+ expectMatch: true,
+ expectedPath: "http://test.example.io/bar/",
+ },
+ "exact_url_match": {
+ rule: "http://pages.example.io/foo/ http://test.example.io/bar/",
+ path: "http://pages.example.io/foo/",
+ expectMatch: true,
+ expectedPath: "http://test.example.io/bar/",
+ },
+ "single_trailing_slash": {
+ rule: "http://pages.example.io/foo/ http://test.example.io/bar/",
+ path: "http://pages.example.io/foo",
+ expectMatch: true,
+ expectedPath: "http://test.example.io/bar/",
+ },
+ "ignore_missing_slash": {
+ rule: "http://pages.example.io/foo http://test.example.io/bar/",
+ path: "http://pages.example.io/foo/",
+ expectMatch: true,
+ expectedPath: "http://test.example.io/bar/",
+ },
+ "no_match": {
+ rule: "http://pages.example.io/foo http://test.example.io/bar/",
+ path: "http://pages.example.io/foo/bar",
+ expectMatch: false,
+ expectedPath: "",
+ },
+
+ // Note: the following 3 cases behave differently when
+ // placeholders are disabled. See the similar test cases below.
+ "multiple_trailing_slashes": {
+ rule: "http://pages.example.io/foo/ http://test.example.io/bar/",
+ path: "http://pages.example.io/foo//",
+ expectMatch: true,
+ expectedPath: "http://test.example.io/bar/",
+ },
+ "multiple_slashes_in_middle": {
+ rule: "http://pages.example.io/foo/bar http://test.example.io/baz/",
+ path: "http://pages.example.io/foo//bar",
+ expectMatch: true,
+ expectedPath: "http://test.example.io/baz/",
+ },
+
+ "domain_redirect_different_protocol": {
+ rule: "http://pages.example.io/foo/bar https://test.example.io/baz/",
+ path: "http://pages.example.io/foo//bar",
+ expectMatch: true,
+ expectedPath: "https://test.example.io/baz/",
+ },
+
+ "splat_match": {
+ rule: "http://pages.example.io/foo/*/bar http://test.example.io/foo/:splat/qux",
+ path: "http://pages.example.io/foo/baz/bar",
+ expectMatch: true,
+ expectedPath: "http://test.example.io/foo/baz/qux",
+ },
+ "splat_match_multiple_segments": {
+ rule: "http://pages.example.io/foo/*/bar http://test.example.io/foo/:splat/qux",
+ path: "http://pages.example.io/foo/hello/world/bar",
+ expectMatch: true,
+ expectedPath: "http://test.example.io/foo/hello/world/qux",
+ },
+ "splat_match_ignore_trailing_slash": {
+ rule: "http://pages.example.io/foo/*/bar http://test.example.io/foo/:splat/qux",
+ path: "http://pages.example.io/foo/baz/bar/",
+ expectMatch: true,
+ expectedPath: "http://test.example.io/foo/baz/qux",
+ },
+ "splat_match_end": {
+ rule: "http://pages.example.io/foo/* http://test.example.io/qux/:splat",
+ path: "http://pages.example.io/foo/baz/bar",
+ expectMatch: true,
+ expectedPath: "http://test.example.io/qux/baz/bar",
+ },
+ "splat_match_end_with_slash": {
+ rule: "http://pages.example.io/foo/* http://test.example.io/qux/:splat",
+ path: "http://pages.example.io/foo/baz/bar/",
+ expectMatch: true,
+ expectedPath: "http://test.example.io/qux/baz/bar/",
+ },
+ "splat_match_beginning": {
+ rule: "http://pages.example.io/*/baz/bar http://test.example.io/qux/:splat",
+ path: "http://pages.example.io/foo/baz/bar",
+ expectMatch: true,
+ expectedPath: "http://test.example.io/qux/foo",
+ },
+ "splat_match_empty_suffix": {
+ rule: "http://pages.example.io/foo/* http://test.example.io/qux/:splat",
+ path: "http://pages.example.io/foo/",
+ expectMatch: true,
+ expectedPath: "http://test.example.io/qux/",
+ },
+ "splat_consumes_trailing_slash": {
+ rule: "http://pages.example.io/foo/* http://test.example.io/qux/:splat",
+ path: "http://pages.example.io/foo",
+ expectMatch: true,
+ expectedPath: "http://test.example.io/qux/",
+ },
+ "splat_match_empty_prefix": {
+ rule: "http://pages.example.io/*/foo http://test.example.io/qux/:splat",
+ path: "http://pages.example.io/foo",
+ expectMatch: true,
+ expectedPath: "http://test.example.io/qux/",
+ },
+ "splat_mid_segment": {
+ rule: "http://pages.example.io/foo*bar http://test.example.io/qux/:splat",
+ path: "http://pages.example.io/foobazbar",
+ expectMatch: false,
+ expectedPath: "",
+ },
+ "splat_mid_segment_no_content": {
+ rule: "http://pages.example.io/foo*bar http://test.example.io/qux/:splat",
+ path: "http://pages.example.io/foobar",
+ expectMatch: false,
+ expectedPath: "",
+ },
+ "lone_splat": {
+ rule: "http://pages.example.io/* http://test.example.io/qux/:splat",
+ path: "http://pages.example.io/foo/bar",
+ expectMatch: true,
+ expectedPath: "http://test.example.io/qux/foo/bar",
+ },
+ "duplicate_splat_replacements": {
+ rule: "http://pages.example.io/foo/* http://test.example.io/qux/:splat/:splat",
+ path: "http://pages.example.io/foo/hello",
+ expectMatch: true,
+ expectedPath: "http://test.example.io/qux/hello/hello",
+ },
+ "splat_missing_path_segment_behavior": {
+ rule: "http://pages.example.io/foo/*/bar http://test.example.io/foo/:splat/qux",
+ path: "http://pages.example.io/foo/bar",
+ expectMatch: true,
+ expectedPath: "http://test.example.io/foo/qux",
+ },
+ "missing_splat_placeholder": {
+ rule: "http://pages.example.io/foo/ http://test.example.io/qux/:splat",
+ path: "http://pages.example.io/foo/",
+ expectMatch: true,
+ expectedPath: "http://test.example.io/qux/",
+ },
+ "placeholder_match": {
+ rule: "http://pages.example.io/foo/:year/:month/:day/bar http://test.example.io/qux/:year-:month-:day",
+ path: "http://pages.example.io/foo/2021/08/16/bar",
+ expectMatch: true,
+ expectedPath: "http://test.example.io/qux/2021-08-16",
+ },
+ "placeholder_match_end": {
+ rule: "http://pages.example.io/foo/:placeholder http://test.example.io/qux/:placeholder",
+ path: "http://pages.example.io/foo/bar",
+ expectMatch: true,
+ expectedPath: "http://test.example.io/qux/bar",
+ },
+ "placeholder_match_beginning": {
+ rule: "http://pages.example.io/:placeholder/foo http://test.example.io/qux/:placeholder",
+ path: "http://pages.example.io/baz/foo",
+ expectMatch: true,
+ expectedPath: "http://test.example.io/qux/baz",
+ },
+ "placeholder_no_multiple_segments": {
+ rule: "http://pages.example.io/foo/:placeholder/bar http://test.example.io/foo/:placeholder/qux",
+ path: "http://pages.example.io/foo/hello/world/bar",
+ expectMatch: false,
+ expectedPath: "",
+ },
+ "placeholder_at_beginning_no_content": {
+ rule: "http://pages.example.io/:placeholder/foo http://test.example.io/qux/:placeholder",
+ path: "http://pages.example.io/foo",
+ expectMatch: false,
+ expectedPath: "",
+ },
+ "placeholder_at_end_no_content": {
+ rule: "http://pages.example.io/foo/:placeholder http://test.example.io/qux/:placeholder",
+ path: "http://pages.example.io/foo/",
+ expectMatch: false,
+ expectedPath: "",
+ },
+ "placeholder_mid_segment_in_from": {
+ rule: "http://pages.example.io/foo:placeholder http://test.example.io/qux/:placeholder",
+ path: "http://pages.example.io/foorbar",
+ expectMatch: false,
+ expectedPath: "",
+ },
+ "placeholder_mid_segment_in_to": {
+ rule: "http://pages.example.io/foo/:placeholder http://test.example.io/qux/bar:placeholder",
+ path: "http://pages.example.io/foo/baz",
+ expectMatch: true,
+ expectedPath: "http://test.example.io/qux/barbaz",
+ },
+ "placeholder_missing_replacement_with_substring": {
+ rule: "http://pages.example.io/:foo http://test.example.io/:foobar",
+ path: "http://pages.example.io/baz",
+ expectMatch: true,
+ expectedPath: "http://test.example.io/",
+ },
+ "placeholder_mid_segment_no_content": {
+ rule: "http://pages.example.io/foo:placeholder http://test.example.io/qux/:splat",
+ path: "http://pages.example.io/foo",
+ expectMatch: false,
+ expectedPath: "",
+ },
+ "placeholder_name_substring": {
+ rule: "http://pages.example.io/foo/:foo/:foobar http://test.example.io/qux/:foo/:foobar",
+ path: "http://pages.example.io/foo/baz/quux",
+ expectMatch: true,
+ expectedPath: "http://test.example.io/qux/baz/quux",
+ },
+ "lone_placeholder": {
+ rule: "http://pages.example.io/:placeholder http://test.example.io/qux/:placeholder",
+ path: "http://pages.example.io/foo",
+ expectMatch: true,
+ expectedPath: "http://test.example.io/qux/foo",
+ },
+ "duplicate_placeholders": {
+ rule: "http://pages.example.io/foo/:placeholder/bar/:placeholder/baz http://test.example.io/qux/:placeholder",
+ path: "http://pages.example.io/foo/hello/bar/world/baz",
+ expectMatch: true,
+ expectedPath: "http://test.example.io/qux/hello",
+ },
+ "duplicate_placeholder_replacements": {
+ rule: "http://pages.example.io/foo/:placeholder http://test.example.io/qux/:placeholder/:placeholder",
+ path: "http://pages.example.io/foo/hello",
+ expectMatch: true,
+ expectedPath: "http://test.example.io/qux/hello/hello",
+ },
+ "splat_and_placeholder_named_splat": {
+ rule: "http://pages.example.io/foo/*/bar/:splat http://test.example.io/qux/:splat",
+ path: "http://pages.example.io/foo/hello/bar/world",
+ expectMatch: true,
+ expectedPath: "http://test.example.io/qux/hello",
+ },
+ "placeholder_named_splat_and_splat": {
+ rule: "http://pages.example.io/foo/:splat/bar/* http://test.example.io/qux/:splat",
+ path: "http://pages.example.io/foo/hello/bar/world",
+ expectMatch: true,
+ expectedPath: "http://test.example.io/qux/hello",
+ },
+
+ // Note: These differ slightly from Netlify's matching behavior.
+ // GitLab replaces _all_ placeholders in the "to" path, even if
+ // the placeholder doesn't have corresponding match in the "from".
+ // Netlify only replaces placeholders that appear in the "from".
+ "missing_placeholder_exact_match": {
+ rule: "http://pages.example.io/foo/ http://test.example.io/qux/:placeholder",
+ path: "http://pages.example.io/foo/",
+ expectMatch: true,
+
+ // Netlify would instead redirect to "/qux/:placeholder"
+ expectedPath: "http://test.example.io/qux/",
+ },
+ "missing_placeholder_nonexact_match": {
+ rule: "http://pages.example.io/foo/:placeholderA http://test.example.io/qux/:placeholderB",
+ path: "http://pages.example.io/foo/bar",
+ expectMatch: true,
+
+ // Netlify would instead redirect to "/qux/:placeholderB"
+ expectedPath: "http://test.example.io/qux/",
+ },
+ }
+
+ for name, tt := range tests {
+ t.Run(name, func(t *testing.T) {
+ rules, err := netlifyRedirects.ParseString(tt.rule)
+ require.NoError(t, err)
+
+ parsedURL, err := url.Parse(tt.path)
+ require.NoError(t, err)
+
+ isMatch, path := matchesRule(&rules[0], parsedURL)
+ require.Equal(t, tt.expectMatch, isMatch)
+ require.Equal(t, tt.expectedPath, path)
+ })
+ }
+}
+
+// Tests matching behavior when the `FF_ENABLE_DOMAIN_REDIRECT`
+// feature flag is not enabled. These tests can be removed when the
+// `FF_ENABLE_DOMAIN_REDIRECT` flag is removed.
+func Test_matchesDomainRule_DomainRedirect_Disabled(t *testing.T) {
+ t.Setenv(feature.RedirectsPlaceholders.EnvVariable, "true")
+ t.Setenv(feature.DomainRedirects.EnvVariable, "false")
+
+ tests := map[string]testCaseData{
+ "exact_match": {
+ rule: "http://pages.example.io/foo/ http://test.example.io/bar/",
+ path: "http://pages.example.io/foo/",
+ expectMatch: false,
+ expectedPath: "",
+ },
+ }
+
+ for name, tt := range tests {
+ t.Run(name, func(t *testing.T) {
+ rules, err := netlifyRedirects.ParseString(tt.rule)
+ require.NoError(t, err)
+
+ parsedURL, err := url.Parse(tt.path)
+ require.NoError(t, err)
+
+ isMatch, path := matchesRule(&rules[0], parsedURL)
require.Equal(t, tt.expectMatch, isMatch)
require.Equal(t, tt.expectedPath, path)
})
diff --git a/internal/redirects/redirects.go b/internal/redirects/redirects.go
index 4903518a..2c01f396 100644
--- a/internal/redirects/redirects.go
+++ b/internal/redirects/redirects.go
@@ -52,8 +52,11 @@ var (
errFailedToParseConfig = errors.New("failed to parse _redirects file")
errFailedToParseURL = errors.New("unable to parse URL")
errNoDomainLevelRedirects = errors.New("no domain-level redirects to outside sites")
+ errNoDomainLevelRewrite = errors.New("no domain-level rewrite to outside sites")
errNoStartingForwardSlashInURLPath = errors.New("url path must start with forward slash /")
+ errNoValidStartingInURLPath = errors.New("url path must start with forward slash / or http:// or https://")
errNoSplats = errors.New("splats are not enabled. See https://docs.gitlab.com/ee/user/project/pages/redirects.html#feature-flag-for-rewrites")
+ errMoreThanOneSplats = errors.New("rule cannot contain more than 1 asterisk (*) in its from path")
errNoPlaceholders = errors.New("placeholders are not enabled. See https://docs.gitlab.com/ee/user/project/pages/redirects.html#feature-flag-for-rewrites")
errNoParams = errors.New("params not supported")
errUnsupportedStatus = errors.New("status not supported")
@@ -111,7 +114,7 @@ func (r *Redirects) Rewrite(originalURL *url.URL) (*url.URL, int, error) {
return nil, 0, ErrNoRedirect
}
- rule, newPath := r.match(originalURL.Path)
+ rule, newPath := r.match(originalURL)
if rule == nil {
return nil, 0, ErrNoRedirect
}
diff --git a/internal/redirects/utils.go b/internal/redirects/utils.go
new file mode 100644
index 00000000..b9aa819f
--- /dev/null
+++ b/internal/redirects/utils.go
@@ -0,0 +1,36 @@
+package redirects
+
+import "net/url"
+
+// isDomainURL checks if the given urlString is a valid domain URL with scheme and host parts.
+// Returns true if urlString is a valid domain URL, false otherwise.
+func isDomainURL(urlString string) bool {
+ parsedURL, err := url.Parse(urlString)
+ if err != nil {
+ return false
+ }
+ if len(parsedURL.Scheme) > 0 && len(parsedURL.Host) > 0 {
+ return true
+ }
+ return false
+}
+
+// Write documentation for below method
+// matchHost checks if the originalURL matches the domain and path provided in path argument.
+// It returns a bool indicating if there is a host match and the matched path.
+// It returns true and path when path does not contain scheme and host
+func matchHost(originalURL *url.URL, path string) (bool, string) {
+ if !isDomainURL(path) {
+ return true, path
+ }
+
+ parsedURL, err := url.Parse(path)
+ if err != nil {
+ return false, ""
+ }
+
+ if originalURL.Scheme == parsedURL.Scheme && originalURL.Host == parsedURL.Host {
+ return true, parsedURL.Path
+ }
+ return false, ""
+}
diff --git a/internal/redirects/utils_test.go b/internal/redirects/utils_test.go
new file mode 100644
index 00000000..cda24e30
--- /dev/null
+++ b/internal/redirects/utils_test.go
@@ -0,0 +1,83 @@
+package redirects
+
+import (
+ "net/url"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestIsDomainURL(t *testing.T) {
+ tests := map[string]struct {
+ url string
+ expectedBool bool
+ }{
+ "only_path": {
+ url: "/goto.html",
+ expectedBool: false,
+ },
+ "valid_domain_url": {
+ url: "https://GitLab.com",
+ expectedBool: true,
+ },
+ "schemaless_domain_url_with_special_char": {
+ url: "/\\GitLab.com",
+ expectedBool: false,
+ },
+ "schemaless_domain_url": {
+ url: "//GitLab.com/pages.html",
+ expectedBool: false,
+ },
+ }
+
+ for name, tt := range tests {
+ t.Run(name, func(t *testing.T) {
+ require.EqualValues(t, tt.expectedBool, isDomainURL(tt.url))
+ })
+ }
+}
+
+func TestMatchHost(t *testing.T) {
+ tests := map[string]struct {
+ originalURL string
+ path string
+ expectedBool bool
+ expectedPath string
+ }{
+ "path_without_domain": {
+ originalURL: "https://GitLab.com/goto.html",
+ path: "/goto.html",
+ expectedBool: true,
+ expectedPath: "/goto.html",
+ },
+ "valid_matching_host": {
+ originalURL: "https://GitLab.com/goto.html",
+ path: "https://GitLab.com/goto.html",
+ expectedBool: true,
+ expectedPath: "/goto.html",
+ },
+ "different_schema_path": {
+ originalURL: "http://GitLab.com/goto.html",
+ path: "https://GitLab.com/goto.html",
+ expectedBool: false,
+ expectedPath: "",
+ },
+ "different_host_path": {
+ originalURL: "https://GitLab.com/goto.html",
+ path: "https://GitLab-test.com/goto.html",
+ expectedBool: false,
+ expectedPath: "",
+ },
+ }
+
+ for name, tt := range tests {
+ t.Run(name, func(t *testing.T) {
+ parsedURL, err := url.Parse(tt.originalURL)
+ require.NoError(t, err)
+
+ hostMatches, path := matchHost(parsedURL, tt.path)
+ require.EqualValues(t, tt.expectedBool, hostMatches)
+ require.EqualValues(t, tt.expectedPath, path)
+ })
+ }
+}
diff --git a/internal/redirects/validations.go b/internal/redirects/validations.go
index 86fb0212..c469ecfc 100644
--- a/internal/redirects/validations.go
+++ b/internal/redirects/validations.go
@@ -18,43 +18,110 @@ var (
regexPlaceholderReplacement = regexp.MustCompile(`(?i):(?P<placeholder>[a-z]+)`)
)
-// validateURL runs validations against a rule URL.
+// validateFromURL validates the from URL in a redirect rule.
+// It checks for various invalid cases like unsupported schemes,
+// relative URLs, domain redirects without scheme, etc.
// Returns `nil` if the URL is valid.
-func validateURL(urlText string) error {
- url, err := url.Parse(urlText)
+// nolint: gocyclo
+func validateFromURL(urlText string) error {
+ fromURL, err := url.Parse(urlText)
if err != nil {
return errFailedToParseURL
}
- // No support for domain-level redirects to outside sites:
- // - `https://google.com`
+ // No support for domain level redirects starting with special characters without scheme:
// - `//google.com`
// - `/\google.com`
- if url.Host != "" || url.Scheme != "" || strings.HasPrefix(url.Path, "/\\") {
- return errNoDomainLevelRedirects
+ if (fromURL.Host == "") != (fromURL.Scheme == "") || strings.HasPrefix(fromURL.Path, "/\\") {
+ return errNoValidStartingInURLPath
+ }
+
+ if fromURL.Scheme != "" && fromURL.Scheme != "http" && fromURL.Scheme != "https" {
+ return errNoValidStartingInURLPath
+ }
+
+ if fromURL.Scheme == "" && fromURL.Host == "" {
+ // No parent traversing relative URL's with `./` or `../`
+ // No ambiguous URLs like bare domains `GitLab.com`
+ if !strings.HasPrefix(urlText, "/") {
+ return errNoValidStartingInURLPath
+ }
+ }
+
+ if feature.RedirectsPlaceholders.Enabled() && strings.Count(fromURL.Path, "/*") > 1 {
+ return errMoreThanOneSplats
}
- // No parent traversing relative URL's with `./` or `../`
- // No ambiguous URLs like bare domains `GitLab.com`
- if !strings.HasPrefix(url.Path, "/") {
- return errNoStartingForwardSlashInURLPath
+ return validateSplatAndPlaceholders(fromURL.Path)
+}
+
+// validateURL runs validations against a rule URL.
+// Returns `nil` if the URL is valid.
+// nolint: gocyclo
+func validateToURL(urlText string, status int) error {
+ toURL, err := url.Parse(urlText)
+ if err != nil {
+ return errFailedToParseURL
}
+ allowedPrefix := []string{"/"}
+ if feature.DomainRedirects.Enabled() {
+ // No support for domain level redirects starting with // or special characters:
+ // - `//google.com`
+ // - `/\google.com`
+ if (toURL.Host == "") != (toURL.Scheme == "") || strings.HasPrefix(toURL.Path, "/\\") {
+ return errNoValidStartingInURLPath
+ }
+
+ // No support for domain level rewrite
+ if isDomainURL(urlText) {
+ if status == http.StatusOK {
+ return errNoDomainLevelRewrite
+ }
+ allowedPrefix = append(allowedPrefix, "http://", "https://")
+ }
+
+ // No parent traversing relative URL's with `./` or `../`
+ // No ambiguous URLs like bare domains `GitLab.com`
+ if !startsWithAnyPrefix(urlText, allowedPrefix...) {
+ return errNoValidStartingInURLPath
+ }
+ } else {
+ // No support for domain-level redirects to outside sites:
+ // - `https://google.com`
+ // - `//google.com`
+ // - `/\google.com`
+ if toURL.Host != "" || toURL.Scheme != "" || strings.HasPrefix(toURL.Path, "/\\") {
+ return errNoDomainLevelRedirects
+ }
+
+ // No parent traversing relative URL's with `./` or `../`
+ // No ambiguous URLs like bare domains `GitLab.com`
+ if !startsWithAnyPrefix(urlText, allowedPrefix...) {
+ return errNoStartingForwardSlashInURLPath
+ }
+ }
+
+ return validateSplatAndPlaceholders(toURL.Path)
+}
+
+func validateSplatAndPlaceholders(path string) error {
if feature.RedirectsPlaceholders.Enabled() {
+ maxPathSegments := cfg.MaxPathSegments
// Limit the number of path segments a rule can contain.
// This prevents the matching logic from generating regular
// expressions that are too large/complex.
- if strings.Count(url.Path, "/") > cfg.MaxPathSegments {
+ if strings.Count(path, "/") > maxPathSegments {
return fmt.Errorf("url path cannot contain more than %d forward slashes", cfg.MaxPathSegments)
}
} else {
// No support for splats, https://docs.netlify.com/routing/redirects/redirect-options/#splats
- if strings.Contains(url.Path, "*") {
+ if strings.Contains(path, "*") {
return errNoSplats
}
// No support for placeholders, https://docs.netlify.com/routing/redirects/redirect-options/#placeholders
- if regexpPlaceholder.MatchString(url.Path) {
+ if regexpPlaceholder.MatchString(path) {
return errNoPlaceholders
}
}
@@ -65,11 +132,11 @@ func validateURL(urlText string) error {
// validateRule runs all validation rules on the provided rule.
// Returns `nil` if the rule is valid
func validateRule(r netlifyRedirects.Rule) error {
- if err := validateURL(r.From); err != nil {
+ if err := validateFromURL(r.From); err != nil {
return err
}
- if err := validateURL(r.To); err != nil {
+ if err := validateToURL(r.To, r.Status); err != nil {
return err
}
@@ -93,3 +160,12 @@ func validateRule(r netlifyRedirects.Rule) error {
return nil
}
+
+func startsWithAnyPrefix(s string, prefixes ...string) bool {
+ for _, prefix := range prefixes {
+ if strings.HasPrefix(s, prefix) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/redirects/validations_test.go b/internal/redirects/validations_test.go
index 2b8ac1b1..13f115cd 100644
--- a/internal/redirects/validations_test.go
+++ b/internal/redirects/validations_test.go
@@ -2,6 +2,7 @@ package redirects
import (
"fmt"
+ "net/http"
"strings"
"testing"
@@ -11,7 +12,68 @@ import (
"gitlab.com/gitlab-org/gitlab-pages/internal/feature"
)
-func TestRedirectsValidateUrl(t *testing.T) {
+func TestRedirectsValidateFromUrl(t *testing.T) {
+ t.Setenv(feature.RedirectsPlaceholders.EnvVariable, "true")
+
+ tests := map[string]struct {
+ url string
+ expectedErr error
+ }{
+ "valid_url": {
+ url: "/goto.html",
+ },
+ "no_domain_level_redirects": {
+ url: "https://GitLab.com",
+ },
+ "no_special_characters_escape_domain_level_redirects": {
+ url: "/\\GitLab.com",
+ expectedErr: errNoValidStartingInURLPath,
+ },
+ "no_schemaless_url_domain_level_redirects": {
+ url: "//GitLab.com/pages.html",
+ expectedErr: errNoValidStartingInURLPath,
+ },
+ "no_bare_domain_level_redirects": {
+ url: "GitLab.com",
+ expectedErr: errNoValidStartingInURLPath,
+ },
+ "no_parent_traversing_relative_url": {
+ url: "../target.html",
+ expectedErr: errNoValidStartingInURLPath,
+ },
+ "too_many_slashes": {
+ url: strings.Repeat("/a", 26),
+ expectedErr: fmt.Errorf("url path cannot contain more than %d forward slashes", defaultMaxPathSegments),
+ },
+ "placeholders": {
+ url: "/news/:year/:month/:date/:slug",
+ },
+ "splats": {
+ url: "/blog/*",
+ },
+ "no_multiple_splats_redirects": {
+ url: "/foo/*/bar/*/baz",
+ expectedErr: errMoreThanOneSplats,
+ },
+ "splat_placeholders": {
+ url: "/new/path/:splat",
+ },
+ }
+
+ for name, tt := range tests {
+ t.Run(name, func(t *testing.T) {
+ err := validateFromURL(tt.url)
+ if tt.expectedErr != nil {
+ require.EqualError(t, err, tt.expectedErr.Error())
+ return
+ }
+
+ require.NoError(t, err)
+ })
+ }
+}
+
+func TestRedirectsValidateToUrl(t *testing.T) {
t.Setenv(feature.RedirectsPlaceholders.EnvVariable, "true")
tests := map[string]struct {
@@ -58,7 +120,7 @@ func TestRedirectsValidateUrl(t *testing.T) {
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
- err := validateURL(tt.url)
+ err := validateToURL(tt.url, http.StatusMovedPermanently)
if tt.expectedErr != nil {
require.EqualError(t, err, tt.expectedErr.Error())
return
@@ -92,7 +154,7 @@ func TestRedirectsValidateUrlNoPlaceholders(t *testing.T) {
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
- err := validateURL(tt.url)
+ err := validateToURL(tt.url, http.StatusMovedPermanently)
require.ErrorIs(t, err, tt.expectedErr)
})
}
@@ -108,9 +170,12 @@ func TestRedirectsValidateRule(t *testing.T) {
"valid_rule": {
rule: "/goto.html /target.html 301",
},
+ "valid_from_host_url": {
+ rule: "http://valid.com/ /teapot.html 302",
+ },
"invalid_from_url": {
rule: "invalid.com /teapot.html 302",
- expectedErr: errNoStartingForwardSlashInURLPath,
+ expectedErr: errNoValidStartingInURLPath,
},
"invalid_to_url": {
rule: "/goto.html invalid.com",
@@ -140,3 +205,255 @@ func TestRedirectsValidateRule(t *testing.T) {
})
}
}
+
+func TestRedirectsValidateFromUrl_DomainRedirect_Enabled(t *testing.T) {
+ t.Setenv(feature.RedirectsPlaceholders.EnvVariable, "true")
+
+ tests := map[string]struct {
+ url string
+ expectedErr error
+ }{
+ "valid_url": {
+ url: "/goto.html",
+ },
+ "domain_level": {
+ url: "https://GitLab.com",
+ },
+ "no_special_characters_escape_domain_level": {
+ url: "/\\GitLab.com",
+ expectedErr: errNoValidStartingInURLPath,
+ },
+ "no_schemaless_url_domain_level": {
+ url: "//GitLab.com/pages.html",
+ expectedErr: errNoValidStartingInURLPath,
+ },
+ "no_bare_domain_level": {
+ url: "GitLab.com",
+ expectedErr: errNoValidStartingInURLPath,
+ },
+ "no_parent_traversing_relative_url": {
+ url: "../target.html",
+ expectedErr: errNoValidStartingInURLPath,
+ },
+ "too_many_slashes": {
+ url: strings.Repeat("/a", 26),
+ expectedErr: fmt.Errorf("url path cannot contain more than %d forward slashes", defaultMaxPathSegments),
+ },
+ "placeholders": {
+ url: "/news/:year/:month/:date/:slug",
+ },
+ "domain_redirect_placeholders": {
+ url: "https://GitLab.com/news/:year/:month/:date/:slug",
+ },
+ "splats": {
+ url: "/blog/*",
+ },
+ "lone_splats": {
+ url: "https://GitLab.com/*",
+ },
+ "domain_redirect_splats": {
+ url: "https://GitLab.com/blog/*",
+ },
+ "splat_placeholders": {
+ url: "/new/path/:splat",
+ },
+ "domain_redirect_splat_placeholders": {
+ url: "https://GitLab.com/new/path/:splat",
+ },
+ }
+
+ for name, tt := range tests {
+ t.Run(name, func(t *testing.T) {
+ err := validateFromURL(tt.url)
+ if tt.expectedErr != nil {
+ require.EqualError(t, err, tt.expectedErr.Error())
+ return
+ }
+
+ require.NoError(t, err)
+ })
+ }
+}
+
+func TestRedirectsValidateToUrl_DomainRedirect_Enabled(t *testing.T) {
+ t.Setenv(feature.RedirectsPlaceholders.EnvVariable, "true")
+ t.Setenv(feature.DomainRedirects.EnvVariable, "true")
+
+ tests := map[string]struct {
+ url string
+ status int
+ expectedErr error
+ }{
+ "valid_url": {
+ url: "/goto.html",
+ status: http.StatusOK,
+ },
+ "domain_level_redirects": {
+ url: "https://GitLab.com",
+ status: http.StatusMovedPermanently,
+ },
+ "domain_level_rewrite": {
+ url: "https://GitLab.com",
+ status: http.StatusOK,
+ expectedErr: errNoDomainLevelRewrite,
+ },
+ "no_special_characters_escape_domain_level_redirects": {
+ url: "/\\GitLab.com",
+ status: http.StatusMovedPermanently,
+ expectedErr: errNoValidStartingInURLPath,
+ },
+ "no_schemaless_url_domain_level_redirects": {
+ url: "//GitLab.com/pages.html",
+ status: http.StatusMovedPermanently,
+ expectedErr: errNoValidStartingInURLPath,
+ },
+ "no_bare_domain_level_redirects": {
+ url: "GitLab.com",
+ status: http.StatusMovedPermanently,
+ expectedErr: errNoValidStartingInURLPath,
+ },
+ "no_parent_traversing_relative_url": {
+ url: "../target.html",
+ status: http.StatusMovedPermanently,
+ expectedErr: errNoValidStartingInURLPath,
+ },
+ "too_many_slashes": {
+ url: strings.Repeat("/a", 26),
+ status: http.StatusMovedPermanently,
+ expectedErr: fmt.Errorf("url path cannot contain more than %d forward slashes", defaultMaxPathSegments),
+ },
+ "placeholders": {
+ url: "/news/:year/:month/:date/:slug",
+ status: http.StatusMovedPermanently,
+ },
+ "domain_redirect_placeholders": {
+ url: "https://GitLab.com/news/:year/:month/:date/:slug",
+ status: http.StatusMovedPermanently,
+ },
+ "splats": {
+ url: "/blog/*",
+ status: http.StatusMovedPermanently,
+ },
+ "lone_splats": {
+ url: "https://GitLab.com/*",
+ status: http.StatusMovedPermanently,
+ },
+ "domain_redirect_splats": {
+ url: "https://GitLab.com/blog/*",
+ status: http.StatusMovedPermanently,
+ },
+ "splat_placeholders": {
+ url: "/new/path/:splat",
+ status: http.StatusMovedPermanently,
+ },
+ "domain_redirect_splat_placeholders": {
+ url: "https://GitLab.com/new/path/:splat",
+ status: http.StatusMovedPermanently,
+ },
+ }
+
+ for name, tt := range tests {
+ t.Run(name, func(t *testing.T) {
+ err := validateToURL(tt.url, tt.status)
+ if tt.expectedErr != nil {
+ require.EqualError(t, err, tt.expectedErr.Error())
+ return
+ }
+
+ require.NoError(t, err)
+ })
+ }
+}
+
+// Tests validation rules that only apply when the `FF_ENABLE_DOMAIN_REDIRECT`
+// feature flag is not enabled. These tests can be removed when the
+// `FF_ENABLE_DOMAIN_REDIRECT` flag is removed.
+func TestRedirectsValidateToUrl_DomainRedirect_Disabled(t *testing.T) {
+ t.Setenv(feature.RedirectsPlaceholders.EnvVariable, "true")
+ t.Setenv(feature.DomainRedirects.EnvVariable, "true")
+
+ tests := map[string]struct {
+ url string
+ status int
+ expectedErr error
+ }{
+ "valid_url": {
+ url: "/goto.html",
+ status: http.StatusOK,
+ },
+ "domain_level_redirects": {
+ url: "https://GitLab.com",
+ status: http.StatusMovedPermanently,
+ },
+ "domain_level_rewrite": {
+ url: "https://GitLab.com",
+ status: http.StatusOK,
+ expectedErr: errNoDomainLevelRewrite,
+ },
+ "no_special_characters_escape_domain_level_redirects": {
+ url: "/\\GitLab.com",
+ status: http.StatusMovedPermanently,
+ expectedErr: errNoValidStartingInURLPath,
+ },
+ "no_schemaless_url_domain_level_redirects": {
+ url: "//GitLab.com/pages.html",
+ status: http.StatusMovedPermanently,
+ expectedErr: errNoValidStartingInURLPath,
+ },
+ "no_bare_domain_level_redirects": {
+ url: "GitLab.com",
+ status: http.StatusMovedPermanently,
+ expectedErr: errNoValidStartingInURLPath,
+ },
+ "no_parent_traversing_relative_url": {
+ url: "../target.html",
+ status: http.StatusMovedPermanently,
+ expectedErr: errNoValidStartingInURLPath,
+ },
+ "too_many_slashes": {
+ url: strings.Repeat("/a", 26),
+ status: http.StatusMovedPermanently,
+ expectedErr: fmt.Errorf("url path cannot contain more than %d forward slashes", defaultMaxPathSegments),
+ },
+ "placeholders": {
+ url: "/news/:year/:month/:date/:slug",
+ status: http.StatusMovedPermanently,
+ },
+ "domain_redirect_placeholders": {
+ url: "https://GitLab.com/news/:year/:month/:date/:slug",
+ status: http.StatusMovedPermanently,
+ },
+ "splats": {
+ url: "/blog/*",
+ status: http.StatusMovedPermanently,
+ },
+ "lone_splats": {
+ url: "https://GitLab.com/*",
+ status: http.StatusMovedPermanently,
+ },
+ "domain_redirect_splats": {
+ url: "https://GitLab.com/blog/*",
+ status: http.StatusMovedPermanently,
+ },
+ "splat_placeholders": {
+ url: "/new/path/:splat",
+ status: http.StatusMovedPermanently,
+ },
+ "domain_redirect_splat_placeholders": {
+ url: "https://GitLab.com/new/path/:splat",
+ status: http.StatusMovedPermanently,
+ },
+ }
+
+ for name, tt := range tests {
+ t.Run(name, func(t *testing.T) {
+ err := validateToURL(tt.url, tt.status)
+ if tt.expectedErr != nil {
+ require.EqualError(t, err, tt.expectedErr.Error())
+ return
+ }
+
+ require.NoError(t, err)
+ })
+ }
+}
diff --git a/internal/serving/disk/helpers.go b/internal/serving/disk/helpers.go
index 96360d49..c919f188 100644
--- a/internal/serving/disk/helpers.go
+++ b/internal/serving/disk/helpers.go
@@ -5,6 +5,7 @@ import (
"io"
"mime"
"net/http"
+ "net/url"
"path/filepath"
"strconv"
"strings"
@@ -36,6 +37,15 @@ func endsWithoutHTMLExtension(path string) bool {
return !strings.HasSuffix(path, ".html")
}
+func cloneURL(originalURL *url.URL) *url.URL {
+ newURL := new(url.URL)
+
+ // Copy relevant fields
+ *newURL = *originalURL
+
+ return newURL
+}
+
// Detect file's content-type either by extension or mime-sniffing.
// Implementation is adapted from Golang's `http.serveContent()`
// See https://github.com/golang/go/blob/902fc114272978a40d2e65c2510a18e870077559/src/net/http/fs.go#L194
diff --git a/internal/serving/disk/reader.go b/internal/serving/disk/reader.go
index a483a8fe..a7d1bb29 100644
--- a/internal/serving/disk/reader.go
+++ b/internal/serving/disk/reader.go
@@ -49,7 +49,10 @@ func (reader *Reader) tryRedirects(h serving.Handler) bool {
r := redirects.ParseRedirects(ctx, root)
- rewrittenURL, status, err := r.Rewrite(h.Request.URL)
+ requestURL := cloneURL(h.Request.URL)
+ // Taking value from h.Request.Host as h.Request.URL.Host is not populated
+ requestURL.Host = h.Request.Host
+ rewrittenURL, status, err := r.Rewrite(requestURL)
if err != nil {
if !errors.Is(err, redirects.ErrNoRedirect) {
// We assume that rewrite failure is not fatal
@@ -65,7 +68,7 @@ func (reader *Reader) tryRedirects(h serving.Handler) bool {
return reader.tryFile(h)
}
- http.Redirect(h.Writer, h.Request, rewrittenURL.Path, status)
+ http.Redirect(h.Writer, h.Request, rewrittenURL.String(), status)
return true
}