feat: kubesphere 4.0 (#6115)

* feat: kubesphere 4.0

Signed-off-by: ci-bot <ci-bot@kubesphere.io>

* feat: kubesphere 4.0

Signed-off-by: ci-bot <ci-bot@kubesphere.io>

---------

Signed-off-by: ci-bot <ci-bot@kubesphere.io>
Co-authored-by: ks-ci-bot <ks-ci-bot@example.com>
Co-authored-by: joyceliu <joyceliu@yunify.com>
This commit is contained in:
KubeSphere CI Bot
2024-09-06 11:05:52 +08:00
committed by GitHub
parent b5015ec7b9
commit 447a51f08b
8557 changed files with 546695 additions and 1146174 deletions

View File

@@ -0,0 +1,238 @@
/*
* Please refer to the LICENSE file in the root directory of the project.
* https://github.com/kubesphere/kubesphere/blob/master/LICENSE
*/
package directives
import (
"net/http"
"net/url"
"path"
"runtime"
"strings"
)
// Following code copied from github.com/caddyserver/caddy/modules/caddyhttp/matchers.go
type (
MatchPath []string
)
func (m MatchPath) Match(req *http.Request) bool {
// Even though RFC 9110 says that path matching is case-sensitive
// (https://www.rfc-editor.org/rfc/rfc9110.html#section-4.2.3),
// we do case-insensitive matching to mitigate security issues
// related to differences between operating systems, applications,
// etc; if case-sensitive matching is needed, the regex matcher
// can be used instead.
reqPath := strings.ToLower(req.URL.Path)
// See #2917; Windows ignores trailing dots and spaces
// when accessing files (sigh), potentially causing a
// security risk (cry) if PHP files end up being served
// as static files, exposing the source code, instead of
// being matched by *.php to be treated as PHP scripts.
if runtime.GOOS == "windows" { // issue #5613
reqPath = strings.TrimRight(reqPath, ". ")
}
repl := req.Context().Value(ReplacerCtxKey).(*Replacer)
for _, matchPattern := range m {
matchPattern = repl.ReplaceAll(matchPattern, "")
// special case: whole path is wildcard; this is unnecessary
// as it matches all requests, which is the same as no matcher
if matchPattern == "*" {
return true
}
// Clean the path, merge doubled slashes, etc.
// This ensures maliciously crafted requests can't bypass
// the path matcher. See #4407. Good security posture
// requires that we should do all we can to reduce any
// funny-looking paths into "normalized" forms such that
// weird variants can't sneak by.
//
// How we clean the path depends on the kind of pattern:
// we either merge slashes or we don't. If the pattern
// has double slashes, we preserve them in the path.
//
// TODO: Despite the fact that the *vast* majority of path
// matchers have only 1 pattern, a possible optimization is
// to remember the cleaned form of the path for future
// iterations; it's just that the way we clean depends on
// the kind of pattern.
mergeSlashes := !strings.Contains(matchPattern, "//")
// if '%' appears in the match pattern, we interpret that to mean
// the intent is to compare that part of the path in raw/escaped
// space; i.e. "%40"=="%40", not "@", and "%2F"=="%2F", not "/"
if strings.Contains(matchPattern, "%") {
reqPathForPattern := CleanPath(req.URL.EscapedPath(), mergeSlashes)
if m.matchPatternWithEscapeSequence(reqPathForPattern, matchPattern) {
return true
}
// doing prefix/suffix/substring matches doesn't make sense
continue
}
reqPathForPattern := CleanPath(reqPath, mergeSlashes)
// for substring, prefix, and suffix matching, only perform those
// special, fast matches if they are the only wildcards in the pattern;
// otherwise we assume a globular match if any * appears in the middle
// special case: first and last characters are wildcard,
// treat it as a fast substring match
if strings.Count(matchPattern, "*") == 2 &&
strings.HasPrefix(matchPattern, "*") &&
strings.HasSuffix(matchPattern, "*") &&
strings.Count(matchPattern, "*") == 2 {
if strings.Contains(reqPathForPattern, matchPattern[1:len(matchPattern)-1]) {
return true
}
continue
}
// only perform prefix/suffix match if it is the only wildcard...
// I think that is more correct most of the time
if strings.Count(matchPattern, "*") == 1 {
// special case: first character is a wildcard,
// treat it as a fast suffix match
if strings.HasPrefix(matchPattern, "*") {
if strings.HasSuffix(reqPathForPattern, matchPattern[1:]) {
return true
}
continue
}
// special case: last character is a wildcard,
// treat it as a fast prefix match
if strings.HasSuffix(matchPattern, "*") {
if strings.HasPrefix(reqPathForPattern, matchPattern[:len(matchPattern)-1]) {
return true
}
continue
}
}
// at last, use globular matching, which also is exact matching
// if there are no glob/wildcard chars; we ignore the error here
// because we can't handle it anyway
matches, _ := path.Match(matchPattern, reqPathForPattern)
if matches {
return true
}
}
return false
}
func (MatchPath) matchPatternWithEscapeSequence(escapedPath, matchPath string) bool {
// We would just compare the pattern against r.URL.Path,
// but the pattern contains %, indicating that we should
// compare at least some part of the path in raw/escaped
// space, not normalized space; so we build the string we
// will compare against by adding the normalized parts
// of the path, then switching to the escaped parts where
// the pattern hints to us wherever % is present.
var sb strings.Builder
// iterate the pattern and escaped path in lock-step;
// increment iPattern every time we consume a char from the pattern,
// increment iPath every time we consume a char from the path;
// iPattern and iPath are our cursors/iterator positions for each string
var iPattern, iPath int
for {
if iPattern >= len(matchPath) || iPath >= len(escapedPath) {
break
}
// get the next character from the request path
pathCh := string(escapedPath[iPath])
var escapedPathCh string
// normalize (decode) escape sequences
if pathCh == "%" && len(escapedPath) >= iPath+3 {
// hold onto this in case we find out the intent is to match in escaped space here;
// we lowercase it even though technically the spec says: "For consistency, URI
// producers and normalizers should use uppercase hexadecimal digits for all percent-
// encodings" (RFC 3986 section 2.1) - we lowercased the matcher pattern earlier in
// provisioning so we do the same here to gain case-insensitivity in equivalence;
// besides, this string is never shown visibly
escapedPathCh = strings.ToLower(escapedPath[iPath : iPath+3])
var err error
pathCh, err = url.PathUnescape(escapedPathCh)
if err != nil {
// should be impossible unless EscapedPath() is giving us an invalid sequence!
return false
}
iPath += 2 // escape sequence is 2 bytes longer than normal char
}
// now get the next character from the pattern
normalize := true
switch matchPath[iPattern] {
case '%':
// escape sequence
// if not a wildcard ("%*"), compare literally; consume next two bytes of pattern
if len(matchPath) >= iPattern+3 && matchPath[iPattern+1] != '*' {
sb.WriteString(escapedPathCh)
iPath++
iPattern += 2
break
}
// escaped wildcard sequence; consume next byte only ('*')
iPattern++
normalize = false
fallthrough
case '*':
// wildcard, so consume until next matching character
remaining := escapedPath[iPath:]
until := len(escapedPath) - iPath // go until end of string...
if iPattern < len(matchPath)-1 { // ...unless the * is not at the end
nextCh := matchPath[iPattern+1]
until = strings.IndexByte(remaining, nextCh)
if until == -1 {
// terminating char of wildcard span not found, so definitely no match
return false
}
}
if until == 0 {
// empty span; nothing to add on this iteration
break
}
next := remaining[:until]
if normalize {
var err error
next, err = url.PathUnescape(next)
if err != nil {
return false // should be impossible anyway
}
}
sb.WriteString(next)
iPath += until
default:
sb.WriteString(pathCh)
iPath++
}
iPattern++
}
// we can now treat rawpath globs (%*) as regular globs (*)
matchPath = strings.ReplaceAll(matchPath, "%*", "*")
// ignore error here because we can't handle it anyway=
matches, _ := path.Match(matchPath, sb.String())
return matches
}

View File

@@ -0,0 +1,269 @@
/*
* Please refer to the LICENSE file in the root directory of the project.
* https://github.com/kubesphere/kubesphere/blob/master/LICENSE
*/
package directives
import (
"context"
"net/http"
"net/url"
"testing"
)
func TestPathMatcher(t *testing.T) {
for i, tc := range []struct {
match MatchPath // not URI-encoded because not parsing from a URI
input string // should be valid URI encoding (escaped) since it will become part of a request
expect bool
provisionErr bool
}{
{
match: MatchPath{},
input: "/",
expect: false,
},
{
match: MatchPath{"/"},
input: "/",
expect: true,
},
{
match: MatchPath{"/foo/bar"},
input: "/",
expect: false,
},
{
match: MatchPath{"/foo/bar"},
input: "/foo/bar",
expect: true,
},
{
match: MatchPath{"/foo/bar/"},
input: "/foo/bar",
expect: false,
},
{
match: MatchPath{"/foo/bar/"},
input: "/foo/bar/",
expect: true,
},
{
match: MatchPath{"/foo/bar/", "/other"},
input: "/other/",
expect: false,
},
{
match: MatchPath{"/foo/bar/", "/other"},
input: "/other",
expect: true,
},
{
match: MatchPath{"*.ext"},
input: "/foo/bar.ext",
expect: true,
},
{
match: MatchPath{"*.php"},
input: "/index.PHP",
expect: true,
},
{
match: MatchPath{"*.ext"},
input: "/foo/bar.ext",
expect: true,
},
{
match: MatchPath{"/foo/*/baz"},
input: "/foo/bar/baz",
expect: true,
},
{
match: MatchPath{"/foo/*/baz/bam"},
input: "/foo/bar/bam",
expect: false,
},
{
match: MatchPath{"*substring*"},
input: "/foo/substring/bar.txt",
expect: true,
},
{
match: MatchPath{"/foo"},
input: "/foo/bar",
expect: false,
},
{
match: MatchPath{"/foo"},
input: "/foo/bar",
expect: false,
},
{
match: MatchPath{"/foo"},
input: "/FOO",
expect: true,
},
{
match: MatchPath{"/foo*"},
input: "/FOOOO",
expect: true,
},
{
match: MatchPath{"/foo/bar.txt"},
input: "/foo/BAR.txt",
expect: true,
},
{
match: MatchPath{"/foo*"},
input: "//foo/bar",
expect: true,
},
{
match: MatchPath{"/foo"},
input: "//foo",
expect: true,
},
{
match: MatchPath{"//foo"},
input: "/foo",
expect: false,
},
{
match: MatchPath{"//foo"},
input: "//foo",
expect: true,
},
{
match: MatchPath{"/foo//*"},
input: "/foo//bar",
expect: true,
},
{
match: MatchPath{"/foo//*"},
input: "/foo/%2Fbar",
expect: true,
},
{
match: MatchPath{"/foo/%2F*"},
input: "/foo//bar",
expect: false,
},
{
match: MatchPath{"/foo//bar"},
input: "/foo//bar",
expect: true,
},
{
match: MatchPath{"/foo/*//bar"},
input: "/foo///bar",
expect: true,
},
{
match: MatchPath{"/foo/%*//bar"},
input: "/foo///bar",
expect: true,
},
{
match: MatchPath{"/foo/%*//bar"},
input: "/foo//%2Fbar",
expect: true,
},
{
match: MatchPath{"/foo*"},
input: "/%2F/foo",
expect: true,
},
{
match: MatchPath{"*"},
input: "/",
expect: true,
},
{
match: MatchPath{"*"},
input: "/foo/bar",
expect: true,
},
{
match: MatchPath{"**"},
input: "/",
expect: true,
},
{
match: MatchPath{"**"},
input: "/foo/bar",
expect: true,
},
// notice these next three test cases are the same normalized path but are written differently
{
match: MatchPath{"/%25@.txt"},
input: "/%25@.txt",
expect: true,
},
{
match: MatchPath{"/%25@.txt"},
input: "/%25%40.txt",
expect: true,
},
{
match: MatchPath{"/%25%40.txt"},
input: "/%25%40.txt",
expect: true,
},
{
match: MatchPath{"/bands/*/*"},
input: "/bands/AC%2FDC/T.N.T",
expect: false, // because * operates in normalized space
},
{
match: MatchPath{"/bands/%*/%*"},
input: "/bands/AC%2FDC/T.N.T",
expect: true,
},
{
match: MatchPath{"/bands/%*/%*"},
input: "/bands/AC/DC/T.N.T",
expect: false,
},
{
match: MatchPath{"/bands/%*"},
input: "/bands/AC/DC",
expect: false, // not a suffix match
},
{
match: MatchPath{"/bands/%*"},
input: "/bands/AC%2FDC",
expect: true,
},
{
match: MatchPath{"/foo%2fbar/baz"},
input: "/foo%2Fbar/baz",
expect: true,
},
{
match: MatchPath{"/foo%2fbar/baz"},
input: "/foo/bar/baz",
expect: false,
},
{
match: MatchPath{"/foo/bar/baz"},
input: "/foo%2fbar/baz",
expect: true,
},
} {
u, err := url.ParseRequestURI(tc.input)
if err != nil {
t.Fatalf("Test %d (%v): Invalid request URI (should be rejected by Go's HTTP server): %v", i, tc.input, err)
}
req := &http.Request{URL: u}
repl := NewReplacer()
ctx := context.WithValue(req.Context(), ReplacerCtxKey, repl)
req = req.WithContext(ctx)
actual := tc.match.Match(req)
if actual != tc.expect {
t.Errorf("Test %d %v: Expected %t, got %t for '%s'", i, tc.match, tc.expect, actual, tc.input)
continue
}
}
}

View File

@@ -0,0 +1,338 @@
/*
* Please refer to the LICENSE file in the root directory of the project.
* https://github.com/kubesphere/kubesphere/blob/master/LICENSE
*/
package directives
import (
"fmt"
"net/http"
"os"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"time"
)
// Following code copied from github.com/caddyserver/caddy/modules/caddyhttp/rewrite/rewrite.go
type substrReplacer struct {
Find string `json:"find,omitempty"`
Replace string `json:"replace,omitempty"`
Limit int `json:"limit,omitempty"`
}
func (rep substrReplacer) do(r *http.Request, repl *Replacer) {
if rep.Find == "" {
return
}
lim := rep.Limit
if lim == 0 {
lim = -1
}
find := repl.ReplaceAll(rep.Find, "")
replace := repl.ReplaceAll(rep.Replace, "")
mergeSlashes := !strings.Contains(rep.Find, "//")
changePath(r, func(pathOrRawPath string) string {
return strings.Replace(CleanPath(pathOrRawPath, mergeSlashes), find, replace, lim)
})
r.URL.RawQuery = strings.Replace(r.URL.RawQuery, find, replace, lim)
}
type regexReplacer struct {
Find string `json:"find,omitempty"`
Replace string `json:"replace,omitempty"`
re *regexp.Regexp
}
func (rep regexReplacer) do(r *http.Request, repl *Replacer) {
if rep.Find == "" || rep.re == nil {
return
}
replace := repl.ReplaceAll(rep.Replace, "")
changePath(r, func(pathOrRawPath string) string {
return rep.re.ReplaceAllString(pathOrRawPath, replace)
})
}
func NewReplacer() *Replacer {
rep := &Replacer{
static: make(map[string]any),
}
rep.providers = []ReplacerFunc{
globalDefaultReplacements,
rep.fromStatic,
}
return rep
}
type Replacer struct {
providers []ReplacerFunc
static map[string]any
}
func (r *Replacer) Map(mapFunc ReplacerFunc) {
r.providers = append(r.providers, mapFunc)
}
func (r *Replacer) Set(variable string, value any) {
r.static[variable] = value
}
func (r *Replacer) Get(variable string) (any, bool) {
for _, mapFunc := range r.providers {
if val, ok := mapFunc(variable); ok {
return val, true
}
}
return nil, false
}
func (r *Replacer) GetString(variable string) (string, bool) {
s, found := r.Get(variable)
return ToString(s), found
}
func (r *Replacer) Delete(variable string) {
delete(r.static, variable)
}
func (r *Replacer) fromStatic(key string) (any, bool) {
val, ok := r.static[key]
return val, ok
}
func (r *Replacer) ReplaceOrErr(input string, errOnEmpty, errOnUnknown bool) (string, error) {
return r.replace(input, "", false, errOnEmpty, errOnUnknown, nil)
}
func (r *Replacer) ReplaceKnown(input, empty string) string {
out, _ := r.replace(input, empty, false, false, false, nil)
return out
}
func (r *Replacer) ReplaceAll(input, empty string) string {
out, _ := r.replace(input, empty, true, false, false, nil)
return out
}
func (r *Replacer) ReplaceFunc(input string, f ReplacementFunc) (string, error) {
return r.replace(input, "", true, false, false, f)
}
func (r *Replacer) replace(input, empty string,
treatUnknownAsEmpty, errOnEmpty, errOnUnknown bool,
f ReplacementFunc,
) (string, error) {
if !strings.Contains(input, string(phOpen)) {
return input, nil
}
var sb strings.Builder
// it is reasonable to assume that the output
// will be approximately as long as the input
sb.Grow(len(input))
// iterate the input to find each placeholder
var lastWriteCursor int
// fail fast if too many placeholders are unclosed
var unclosedCount int
scan:
for i := 0; i < len(input); i++ {
// check for escaped braces
if i > 0 && input[i-1] == phEscape && (input[i] == phClose || input[i] == phOpen) {
sb.WriteString(input[lastWriteCursor : i-1])
lastWriteCursor = i
continue
}
if input[i] != phOpen {
continue
}
// our iterator is now on an unescaped open brace (start of placeholder)
// too many unclosed placeholders in absolutely ridiculous input can be extremely slow (issue #4170)
if unclosedCount > 100 {
return "", fmt.Errorf("too many unclosed placeholders")
}
// find the end of the placeholder
end := strings.Index(input[i:], string(phClose)) + i
if end < i {
unclosedCount++
continue
}
// if necessary look for the first closing brace that is not escaped
for end > 0 && end < len(input)-1 && input[end-1] == phEscape {
nextEnd := strings.Index(input[end+1:], string(phClose))
if nextEnd < 0 {
unclosedCount++
continue scan
}
end += nextEnd + 1
}
// write the substring from the last cursor to this point
sb.WriteString(input[lastWriteCursor:i])
// trim opening bracket
key := input[i+1 : end]
// try to get a value for this key, handle empty values accordingly
val, found := r.Get(key)
if !found {
// placeholder is unknown (unrecognized); handle accordingly
if errOnUnknown {
return "", fmt.Errorf("unrecognized placeholder %s%s%s",
string(phOpen), key, string(phClose))
} else if !treatUnknownAsEmpty {
// if treatUnknownAsEmpty is true, we'll handle an empty
// val later; so only continue otherwise
lastWriteCursor = i
continue
}
}
// apply any transformations
if f != nil {
var err error
val, err = f(key, val)
if err != nil {
return "", err
}
}
valStr := ToString(val)
if valStr == "" {
if errOnEmpty {
return "", fmt.Errorf("evaluated placeholder %s%s%s is empty",
string(phOpen), key, string(phClose))
} else if empty != "" {
sb.WriteString(empty)
}
} else {
sb.WriteString(valStr)
}
i = end
lastWriteCursor = i + 1
}
sb.WriteString(input[lastWriteCursor:])
return sb.String(), nil
}
func ToString(val any) string {
switch v := val.(type) {
case nil:
return ""
case string:
return v
case fmt.Stringer:
return v.String()
case error:
return v.Error()
case byte:
return string(v)
case []byte:
return string(v)
case []rune:
return string(v)
case int:
return strconv.Itoa(v)
case int32:
return strconv.Itoa(int(v))
case int64:
return strconv.Itoa(int(v))
case uint:
return strconv.Itoa(int(v))
case uint32:
return strconv.Itoa(int(v))
case uint64:
return strconv.Itoa(int(v))
case float32:
return strconv.FormatFloat(float64(v), 'f', -1, 32)
case float64:
return strconv.FormatFloat(v, 'f', -1, 64)
case bool:
if v {
return "true"
}
return "false"
default:
return fmt.Sprintf("%+v", v)
}
}
type ReplacerFunc func(key string) (any, bool)
func globalDefaultReplacements(key string) (any, bool) {
const envPrefix = "env."
if strings.HasPrefix(key, envPrefix) {
return os.Getenv(key[len(envPrefix):]), true
}
switch key {
case "system.hostname":
name, _ := os.Hostname()
return name, true
case "system.slash":
return string(filepath.Separator), true
case "system.os":
return runtime.GOOS, true
case "system.wd":
// OK if there is an error; just return empty string
wd, _ := os.Getwd()
return wd, true
case "system.arch":
return runtime.GOARCH, true
case "time.now":
return nowFunc(), true
case "time.now.http":
return nowFunc().UTC().Format(http.TimeFormat), true
case "time.now.common_log":
return nowFunc().Format("02/Jan/2006:15:04:05 -0700"), true
case "time.now.year":
return strconv.Itoa(nowFunc().Year()), true
case "time.now.unix":
return strconv.FormatInt(nowFunc().Unix(), 10), true
case "time.now.unix_ms":
return strconv.FormatInt(nowFunc().UnixNano()/int64(time.Millisecond), 10), true
}
return nil, false
}
// ReplacementFunc is a function that is called when a
// replacement is being performed. It receives the
// variable (i.e. placeholder name) and the value that
// will be the replacement, and returns the value that
// will actually be the replacement, or an error. Note
// that errors are sometimes ignored by replacers.
type ReplacementFunc func(variable string, val any) (any, error)
// nowFunc is a variable so tests can change it
// in order to obtain a deterministic time.
var nowFunc = time.Now
type ContextKey string
// ReplacerCtxKey is the context key for a replacer.
const ReplacerCtxKey ContextKey = "replacer"
const phOpen, phClose, phEscape = '{', '}', '\\'

View File

@@ -0,0 +1,472 @@
/*
* Please refer to the LICENSE file in the root directory of the project.
* https://github.com/kubesphere/kubesphere/blob/master/LICENSE
*/
package directives
import (
"context"
"fmt"
"net/http"
"net/url"
"path"
"regexp"
"strconv"
"strings"
)
// Following code copied from github.com/caddyserver/caddy/modules/caddyhttp/rewrite/rewrite.go
type Rewrite struct {
Method string `json:"method,omitempty"`
URI string `json:"uri,omitempty"`
StripPathPrefix string `json:"strip_path_prefix,omitempty"`
StripPathSuffix string `json:"strip_path_suffix,omitempty"`
URISubstring []substrReplacer `json:"uri_substring,omitempty"`
PathRegexp []*regexReplacer `json:"path_regexp,omitempty"`
}
func (r *Rewrite) Rewrite(req *http.Request, repl *Replacer) bool {
oldMethod := req.Method
oldURI := req.RequestURI
// method
if r.Method != "" {
req.Method = strings.ToUpper(repl.ReplaceAll(r.Method, ""))
}
// uri (path, query string and... fragment, because why not)
if uri := r.URI; uri != "" {
// find the bounds of each part of the URI that exist
pathStart, qsStart, fragStart := -1, -1, -1
pathEnd, qsEnd := -1, -1
loop:
for i, ch := range uri {
switch {
case ch == '?' && qsStart < 0:
pathEnd, qsStart = i, i+1
case ch == '#' && fragStart < 0: // everything after fragment is fragment (very clear in RFC 3986 section 4.2)
if qsStart < 0 {
pathEnd = i
} else {
qsEnd = i
}
fragStart = i + 1
break loop
case pathStart < 0 && qsStart < 0:
pathStart = i
}
}
if pathStart >= 0 && pathEnd < 0 {
pathEnd = len(uri)
}
if qsStart >= 0 && qsEnd < 0 {
qsEnd = len(uri)
}
// isolate the three main components of the URI
var path, query, frag string
if pathStart > -1 {
path = uri[pathStart:pathEnd]
}
if qsStart > -1 {
query = uri[qsStart:qsEnd]
}
if fragStart > -1 {
frag = uri[fragStart:]
}
// build components which are specified, and store them
// in a temporary variable so that they all read the
// same version of the URI
var newPath, newQuery, newFrag string
if path != "" {
// replace the `path` placeholder to escaped path
pathPlaceholder := "{http.request.uri.path}"
if strings.Contains(path, pathPlaceholder) {
path = strings.ReplaceAll(path, pathPlaceholder, req.URL.EscapedPath())
}
newPath = repl.ReplaceAll(path, "")
}
// before continuing, we need to check if a query string
// snuck into the path component during replacements
if before, after, found := strings.Cut(newPath, "?"); found {
// recompute; new path contains a query string
var injectedQuery string
newPath, injectedQuery = before, after
// don't overwrite explicitly-configured query string
if query == "" {
query = injectedQuery
}
}
if query != "" {
newQuery = buildQueryString(query, repl)
}
if frag != "" {
newFrag = repl.ReplaceAll(frag, "")
}
// update the URI with the new components
// only after building them
if pathStart >= 0 {
if path, err := url.PathUnescape(newPath); err != nil {
req.URL.Path = newPath
} else {
req.URL.Path = path
}
}
if qsStart >= 0 {
req.URL.RawQuery = newQuery
}
if fragStart >= 0 {
req.URL.Fragment = newFrag
}
}
// strip path prefix or suffix
if r.StripPathPrefix != "" {
prefix := repl.ReplaceAll(r.StripPathPrefix, "")
mergeSlashes := !strings.Contains(prefix, "//")
changePath(req, func(escapedPath string) string {
escapedPath = CleanPath(escapedPath, mergeSlashes)
return trimPathPrefix(escapedPath, prefix)
})
}
if r.StripPathSuffix != "" {
suffix := repl.ReplaceAll(r.StripPathSuffix, "")
mergeSlashes := !strings.Contains(suffix, "//")
changePath(req, func(escapedPath string) string {
escapedPath = CleanPath(escapedPath, mergeSlashes)
return reverse(trimPathPrefix(reverse(escapedPath), reverse(suffix)))
})
}
// substring replacements in URI
for _, rep := range r.URISubstring {
rep.do(req, repl)
}
// regular expression replacements on the path
for _, rep := range r.PathRegexp {
rep.do(req, repl)
}
// update the encoded copy of the URI
req.RequestURI = req.URL.RequestURI()
// return true if anything changed
return req.Method != oldMethod || req.RequestURI != oldURI
}
func buildQueryString(qs string, repl *Replacer) string {
var sb strings.Builder
// first component must be key, which is the same
// as if we just wrote a value in previous iteration
wroteVal := true
for len(qs) > 0 {
// determine the end of this component, which will be at
// the next equal sign or ampersand, whichever comes first
nextEq, nextAmp := strings.Index(qs, "="), strings.Index(qs, "&")
ampIsNext := nextAmp >= 0 && (nextAmp < nextEq || nextEq < 0)
end := len(qs) // assume no delimiter remains...
if ampIsNext {
end = nextAmp // ...unless ampersand is first...
} else if nextEq >= 0 && (nextEq < nextAmp || nextAmp < 0) {
end = nextEq // ...or unless equal is first.
}
// consume the component and write the result
comp := qs[:end]
comp, _ = repl.ReplaceFunc(comp, func(name string, val any) (any, error) {
if name == "http.request.uri.query" && wroteVal {
return val, nil // already escaped
}
var valStr string
switch v := val.(type) {
case string:
valStr = v
case fmt.Stringer:
valStr = v.String()
case int:
valStr = strconv.Itoa(v)
default:
valStr = fmt.Sprintf("%+v", v)
}
return url.QueryEscape(valStr), nil
})
if end < len(qs) {
end++ // consume delimiter
}
qs = qs[end:]
// if previous iteration wrote a value,
// that means we are writing a key
if wroteVal {
if sb.Len() > 0 && len(comp) > 0 {
sb.WriteRune('&')
}
} else {
sb.WriteRune('=')
}
sb.WriteString(comp)
// remember for the next iteration that we just wrote a value,
// which means the next iteration MUST write a key
wroteVal = ampIsNext
}
return sb.String()
}
func trimPathPrefix(escapedPath, prefix string) string {
var iPath, iPrefix int
for {
if iPath >= len(escapedPath) || iPrefix >= len(prefix) {
break
}
prefixCh := prefix[iPrefix]
ch := string(escapedPath[iPath])
if ch == "%" && prefixCh != '%' && len(escapedPath) >= iPath+3 {
var err error
ch, err = url.PathUnescape(escapedPath[iPath : iPath+3])
if err != nil {
// should be impossible unless EscapedPath() is returning invalid values!
return escapedPath
}
iPath += 2
}
// prefix comparisons are case-insensitive to consistency with
// path matcher, which is case-insensitive for good reasons
if !strings.EqualFold(ch, string(prefixCh)) {
return escapedPath
}
iPath++
iPrefix++
}
// if we iterated through the entire prefix, we found it, so trim it
if iPath >= len(prefix) {
return escapedPath[iPath:]
}
// otherwise we did not find the prefix
return escapedPath
}
func reverse(s string) string {
r := []rune(s)
for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}
func changePath(req *http.Request, newVal func(pathOrRawPath string) string) {
req.URL.RawPath = newVal(req.URL.EscapedPath())
if p, err := url.PathUnescape(req.URL.RawPath); err == nil && p != "" {
req.URL.Path = p
} else {
req.URL.Path = newVal(req.URL.Path)
}
// RawPath is only set if it's different from the normalized Path (std lib)
if req.URL.RawPath == req.URL.Path {
req.URL.RawPath = ""
}
}
func CleanPath(p string, collapseSlashes bool) string {
if collapseSlashes {
return cleanPath(p)
}
// insert an invalid/impossible URI character into each two consecutive
// slashes to expand empty path segments; then clean the path as usual,
// and then remove the remaining temporary characters.
const tmpCh = 0xff
var sb strings.Builder
for i, ch := range p {
if ch == '/' && i > 0 && p[i-1] == '/' {
sb.WriteByte(tmpCh)
}
sb.WriteRune(ch)
}
halfCleaned := cleanPath(sb.String())
halfCleaned = strings.ReplaceAll(halfCleaned, string([]byte{tmpCh}), "")
return halfCleaned
}
// cleanPath does path.Clean(p) but preserves any trailing slash.
func cleanPath(p string) string {
cleaned := path.Clean(p)
if cleaned != "/" && strings.HasSuffix(p, "/") {
cleaned = cleaned + "/"
}
return cleaned
}
type RewriteRule struct {
Match MatchPath
Rewrite Rewrite
}
func (rr *RewriteRule) Exec(req *http.Request) (change bool, err error) {
var repl *Replacer
defer func() {
if panicErr := recover(); panicErr != nil {
err = fmt.Errorf("RewriteRule Err %v", panicErr)
}
}()
replCtx := req.Context().Value(ReplacerCtxKey)
if replCtx == nil || replCtx.(*Replacer) == nil {
repl := NewReplacer()
repl.Set("query", req.URL.RawQuery)
repl.Set("path", req.URL.Path)
ctx := context.WithValue(req.Context(), ReplacerCtxKey, repl)
req = req.WithContext(ctx)
} else {
repl = replCtx.(*Replacer)
}
if rr.Match == nil || rr.Match.Match(req) {
return rr.Rewrite.Rewrite(req, repl), nil
}
return
}
type DirectiveFilter func(rr *[]RewriteRule, expr []string, exprLen int)
type WithDirectiveFilter func(wf *[]DirectiveFilter)
func NewRewriteRulesWithOptions(rules []string, directiveFilters ...WithDirectiveFilter) []RewriteRule {
var rewriteRules = make([]RewriteRule, 0, 1)
if rules == nil {
return rewriteRules
}
var filter = make([]DirectiveFilter, 0, 1)
// inject directiveFilter for filter rewrite/replace/path_regexp
for _, directiveFilter := range directiveFilters {
directiveFilter(&filter)
}
for _, rule := range rules {
expr := strings.Split(rule, " ")
exprLen := len(expr)
for _, directiveFilter := range filter {
directiveFilter(&rewriteRules, expr, exprLen)
}
}
return rewriteRules
}
func WithRewriteFilter(df *[]DirectiveFilter) {
filterFn := func(rr *[]RewriteRule, expr []string, exprLen int) {
if exprLen >= 2 {
rewrite := Rewrite{
URI: expr[1],
}
matchRule := []string{
expr[0],
}
*rr = append(*rr, RewriteRule{
matchRule,
rewrite,
})
}
}
*df = append(*df, filterFn)
}
func WithReplaceFilter(df *[]DirectiveFilter) {
filterFn := func(rr *[]RewriteRule, expr []string, exprLen int) {
if exprLen >= 2 {
rewrite := Rewrite{
URISubstring: []substrReplacer{
{
Find: expr[0],
Replace: expr[1],
Limit: 0,
},
},
}
*rr = append(*rr, RewriteRule{
nil,
rewrite,
})
}
}
*df = append(*df, filterFn)
}
func WithPathRegexpFilter(df *[]DirectiveFilter) {
filterFn := func(rr *[]RewriteRule, expr []string, exprLen int) {
if exprLen >= 2 {
re, err := regexp.Compile(expr[0])
if err != nil {
return
}
rewrite := Rewrite{
PathRegexp: []*regexReplacer{
{
Find: expr[0],
Replace: expr[1],
re: re,
},
},
}
*rr = append(*rr, RewriteRule{
nil,
rewrite,
})
}
}
*df = append(*df, filterFn)
}
func WithStripPrefixFilter(df *[]DirectiveFilter) {
filterFn := func(rr *[]RewriteRule, expr []string, exprLen int) {
if exprLen >= 1 {
*rr = append(*rr, RewriteRule{
nil,
Rewrite{
StripPathPrefix: expr[0],
},
})
}
}
*df = append(*df, filterFn)
}
func WithStripSuffixFilter(df *[]DirectiveFilter) {
filterFn := func(rr *[]RewriteRule, expr []string, exprLen int) {
if exprLen >= 1 {
*rr = append(*rr, RewriteRule{
nil,
Rewrite{
StripPathSuffix: expr[0],
},
})
}
}
*df = append(*df, filterFn)
}
func HandlerRequest(req *http.Request, rules []string, directiveFilters ...WithDirectiveFilter) error {
rewriteRules := NewRewriteRulesWithOptions(rules, directiveFilters...)
for _, rewriteRule := range rewriteRules {
if _, err := rewriteRule.Exec(req); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,225 @@
/*
* Please refer to the LICENSE file in the root directory of the project.
* https://github.com/kubesphere/kubesphere/blob/master/LICENSE
*/
package directives
import (
"context"
"net/http"
"net/url"
"regexp"
"testing"
)
func TestRewrite(t *testing.T) {
repl := NewReplacer()
for i, tc := range []struct {
input, expect *http.Request
rule Rewrite
}{
{
rule: Rewrite{StripPathPrefix: "/api"},
input: newRequest(t, "GET", "/api"),
expect: newRequest(t, "GET", "/"),
},
{
rule: Rewrite{StripPathSuffix: ".html"},
input: newRequest(t, "GET", "/index.html"),
expect: newRequest(t, "GET", "/index"),
},
{
rule: Rewrite{URISubstring: []substrReplacer{
{
Find: "/docs/",
Replace: "/v1/docs/",
Limit: 0,
},
}},
input: newRequest(t, "GET", "/docs/"),
expect: newRequest(t, "GET", "/v1/docs/"),
},
{
rule: Rewrite{PathRegexp: []*regexReplacer{
{
Find: "/{2,}",
Replace: "/",
},
}},
input: newRequest(t, "GET", "/doc//readme.md"),
expect: newRequest(t, "GET", "/doc/readme.md"),
},
} {
// copy the original input just enough so that we can
// compare it after the rewrite to see if it changed
urlCopy := *tc.input.URL
originalInput := &http.Request{
Method: tc.input.Method,
RequestURI: tc.input.RequestURI,
URL: &urlCopy,
}
// populate the replacer just enough for our tests
repl.Set("http.request.uri", tc.input.RequestURI)
repl.Set("http.request.uri.path", tc.input.URL.Path)
repl.Set("http.request.uri.query", tc.input.URL.RawQuery)
for _, rep := range tc.rule.PathRegexp {
re, err := regexp.Compile(rep.Find)
if err != nil {
t.Fatal(err)
}
rep.re = re
}
changed := tc.rule.Rewrite(tc.input, repl)
if expected, actual := !reqEqual(originalInput, tc.input), changed; expected != actual {
t.Errorf("Test %d: Expected changed=%t but was %t", i, expected, actual)
}
if tc.rule.StripPathPrefix != "" {
t.Logf("Test UriRule \"uri strip_prefix %v\" ==> rewrite \"%v\" to \"%v\"", tc.rule.StripPathPrefix, originalInput.URL, tc.input.URL)
} else if tc.rule.StripPathSuffix != "" {
t.Logf("Test UriRule \"uri strip_suffix %v\" ==> rewrite \"%v\" to \"%v\"", tc.rule.StripPathSuffix, originalInput.URL, tc.input.URL)
} else if tc.rule.URISubstring != nil {
t.Logf("Test UriRule \"uri replace %s %s\" ==> rewrite \"%v\" to \"%v\"", tc.rule.URISubstring[0].Find, tc.rule.URISubstring[0].Replace, originalInput.URL, tc.input.URL)
} else if tc.rule.PathRegexp != nil {
t.Logf("Test UriRule \"uri path_regexp %s %s\" ==> rewrite \"%v\" to \"%v\"", (*tc.rule.PathRegexp[0]).Find, (*tc.rule.PathRegexp[0]).Replace, originalInput.URL, tc.input.URL)
}
}
}
func TestPathRewriteRule(t *testing.T) {
for i, tc := range []struct {
rr []RewriteRule // not URI-encoded because not parsing from a URI
input string // should be valid URI encoding (escaped) since it will become part of a request
expect bool
provisionErr bool
}{
{
rr: NewRewriteRulesWithOptions([]string{
"* /foo.html",
}, WithRewriteFilter),
input: "/",
expect: true,
},
{
rr: NewRewriteRulesWithOptions([]string{
"/api/* ?a=b",
}, WithRewriteFilter),
input: "/api/abc",
expect: true,
},
{
rr: NewRewriteRulesWithOptions([]string{
"/api/* ?{query}&a=b",
}, WithRewriteFilter),
input: "/api/abc",
expect: true,
},
{
rr: NewRewriteRulesWithOptions([]string{
"* /index.php?{query}&p={path}",
}, WithRewriteFilter),
input: "/foo/bar",
expect: true,
},
{
rr: NewRewriteRulesWithOptions([]string{
"/api",
}, WithStripPrefixFilter),
input: "/api/v1",
expect: true,
},
{
rr: NewRewriteRulesWithOptions([]string{
".html",
}, WithStripSuffixFilter),
input: "/index.html",
expect: true,
},
{
rr: NewRewriteRulesWithOptions([]string{
"/docs/ /v1/docs/",
}, WithReplaceFilter),
input: "/docs/go",
expect: true,
},
{
rr: NewRewriteRulesWithOptions([]string{
"/{2,} /",
}, WithPathRegexpFilter),
input: "/doc//readme.md",
expect: true,
},
} {
u, err := url.ParseRequestURI(tc.input)
if err != nil {
t.Fatalf("Test %d (%v): Invalid request URI (should be rejected by Go's HTTP server): %v", i, tc.input, err)
}
req := &http.Request{URL: u}
repl := NewReplacer()
repl.Set("query", req.URL.RawQuery)
repl.Set("path", req.URL.Path)
//t.Logf("Init ENV with: {\"query\":\"%v\", \"path\": \"%v\"}", req.URL.RawQuery, req.URL.Path)
ctx := context.WithValue(req.Context(), ReplacerCtxKey, repl)
req = req.WithContext(ctx)
for _, r := range tc.rr {
oldRUL := req.URL.Path
actual, err := r.Exec(req)
if err != nil {
t.Errorf("Test RewriteRule \"rewrite %v %v\" ==> Err %v", r.Match[0], r.Rewrite.URI, err)
continue
}
if actual != tc.expect {
t.Errorf("Test RewriteRule \"rewrite %v %v\" ==> Expected %t, got %t for '%s'", r.Match[0], r.Rewrite.URI, tc.expect, actual, tc.input)
continue
}
if r.Rewrite.StripPathPrefix != "" {
t.Logf("Test RewriteRule \"strip_prefix %v\" ==> rewrite \"%v\" to \"%v\"", r.Rewrite.StripPathPrefix, oldRUL, req.URL)
} else if r.Rewrite.StripPathSuffix != "" {
t.Logf("Test RewriteRule \"strip_suffix %v\" ==> rewrite \"%v\" to \"%v\"", r.Rewrite.StripPathSuffix, oldRUL, req.URL)
} else if r.Rewrite.URISubstring != nil {
t.Logf("Test RewriteRule \"replace %s %s\" ==> rewrite \"%v\" to \"%v\"", r.Rewrite.URISubstring[0].Find, r.Rewrite.URISubstring[0].Replace, oldRUL, req.URL)
} else if r.Rewrite.PathRegexp != nil {
t.Logf("Test RewriteRule \"path_regexp %s %s\" ==> rewrite \"%v\" to \"%v\"", (*r.Rewrite.PathRegexp[0]).Find, (*r.Rewrite.PathRegexp[0]).Replace, oldRUL, req.URL)
} else if r.Rewrite.URI != "" {
t.Logf("Test RewriteRule \"rewrite %s %s\" ==> rewrite \"%v\" to \"%v\"", r.Match[0], r.Rewrite.URI, oldRUL, req.URL)
}
}
}
}
func newRequest(t *testing.T, method, uri string) *http.Request {
req, err := http.NewRequest(method, uri, nil)
if err != nil {
t.Fatalf("error creating request: %v", err)
}
req.RequestURI = req.URL.RequestURI() // simulate incoming request
return req
}
func reqEqual(r1, r2 *http.Request) bool {
if r1.Method != r2.Method {
return false
}
if r1.RequestURI != r2.RequestURI {
return false
}
if (r1.URL == nil && r2.URL != nil) || (r1.URL != nil && r2.URL == nil) {
return false
}
if r1.URL == nil && r2.URL == nil {
return true
}
return r1.URL.Scheme == r2.URL.Scheme &&
r1.URL.Host == r2.URL.Host &&
r1.URL.Path == r2.URL.Path &&
r1.URL.RawPath == r2.URL.RawPath &&
r1.URL.RawQuery == r2.URL.RawQuery &&
r1.URL.Fragment == r2.URL.Fragment
}