mirror of
https://github.com/caddyserver/caddy.git
synced 2026-05-24 16:41:20 -04:00
httpserver/all: Clean up and standardize request URL handling (#1633)
* httpserver/all: Clean up and standardize request URL handling The HTTP server now always creates a context value on the request which is a copy of the request's URL struct. It should not be modified by middlewares, but it is safe to get the value out of the request and make changes to it locally-scoped. Thus, the value in the context always stores the original request URL information as it was received. Any rewrites that happen will be to the request's URL field directly. The HTTP server no longer cleans /sanitizes the request URL. It made too many strong assumptions and ended up making a lot of middleware more complicated, including upstream proxying (and fastcgi). To alleviate this complexity, we no longer change the request URL. Middlewares are responsible to access the disk safely by using http.Dir or, if not actually opening files, they can use httpserver.SafePath(). I'm hoping this will address issues with #1624, #1584, #1582, and others. * staticfiles: Fix test on Windows @abiosoft: I still can't figure out exactly what this is for. 😅 * Use (potentially) changed URL for browse redirects, as before * Use filepath.ToSlash, clean up a couple proxy test cases * Oops, fix variable name
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -94,16 +95,21 @@ func TestConditions(t *testing.T) {
|
||||
for i, test := range replaceTests {
|
||||
r, err := http.NewRequest("GET", test.url, nil)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
t.Errorf("Test %d: failed to create request: %v", i, err)
|
||||
continue
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), OriginalURLCtxKey, *r.URL)
|
||||
r = r.WithContext(ctx)
|
||||
str := strings.Fields(test.condition)
|
||||
ifCond, err := newIfCond(str[0], str[1], str[2])
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
t.Errorf("Test %d: failed to create 'if' condition %v", i, err)
|
||||
continue
|
||||
}
|
||||
isTrue := ifCond.True(r)
|
||||
if isTrue != test.isTrue {
|
||||
t.Errorf("Test %v: expected %v found %v", i, test.isTrue, isTrue)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,15 +198,11 @@ func SameNext(next1, next2 Handler) bool {
|
||||
return fmt.Sprintf("%v", next1) == fmt.Sprintf("%v", next2)
|
||||
}
|
||||
|
||||
// Context key constants
|
||||
// Context key constants.
|
||||
const (
|
||||
// URIxRewriteCtxKey is a context key used to store original unrewritten
|
||||
// URI in context.WithValue
|
||||
URIxRewriteCtxKey caddy.CtxKey = "caddy_rewrite_original_uri"
|
||||
|
||||
// RemoteUserCtxKey is a context key used to store remote user for request
|
||||
// RemoteUserCtxKey is the key for the remote user of the request, if any (basicauth).
|
||||
RemoteUserCtxKey caddy.CtxKey = "remote_user"
|
||||
|
||||
// MitmCtxKey stores Mitm result
|
||||
// MitmCtxKey is the key for the result of MITM detection
|
||||
MitmCtxKey caddy.CtxKey = "mitm"
|
||||
)
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CleanMaskedPath prevents one or more of the path cleanup operations:
|
||||
// - collapse multiple slashes into one
|
||||
// - eliminate "/." (current directory)
|
||||
// - eliminate "<parent_directory>/.."
|
||||
// by masking certain patterns in the path with a temporary random string.
|
||||
// This could be helpful when certain patterns in the path are desired to be preserved
|
||||
// that would otherwise be changed by path.Clean().
|
||||
// One such use case is the presence of the double slashes as protocol separator
|
||||
// (e.g., /api/endpoint/http://example.com).
|
||||
// This is a common pattern in many applications to allow passing URIs as path argument.
|
||||
func CleanMaskedPath(reqPath string, masks ...string) string {
|
||||
var replacerVal string
|
||||
maskMap := make(map[string]string)
|
||||
|
||||
// Iterate over supplied masks and create temporary replacement strings
|
||||
// only for the masks that are present in the path, then replace all occurrences
|
||||
for _, mask := range masks {
|
||||
if strings.Index(reqPath, mask) >= 0 {
|
||||
replacerVal = "/_caddy" + generateRandomString() + "__"
|
||||
maskMap[mask] = replacerVal
|
||||
reqPath = strings.Replace(reqPath, mask, replacerVal, -1)
|
||||
}
|
||||
}
|
||||
|
||||
reqPath = path.Clean(reqPath)
|
||||
|
||||
// Revert the replaced masks after path cleanup
|
||||
for mask, replacerVal := range maskMap {
|
||||
reqPath = strings.Replace(reqPath, replacerVal, mask, -1)
|
||||
}
|
||||
return reqPath
|
||||
}
|
||||
|
||||
// CleanPath calls CleanMaskedPath() with the default mask of "://"
|
||||
// to preserve double slashes of protocols
|
||||
// such as "http://", "https://", and "ftp://" etc.
|
||||
func CleanPath(reqPath string) string {
|
||||
return CleanMaskedPath(reqPath, "://")
|
||||
}
|
||||
|
||||
// An efficient and fast method for random string generation.
|
||||
// Inspired by http://stackoverflow.com/a/31832326.
|
||||
const randomStringLength = 4
|
||||
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
const (
|
||||
letterIdxBits = 6
|
||||
letterIdxMask = 1<<letterIdxBits - 1
|
||||
letterIdxMax = 63 / letterIdxBits
|
||||
)
|
||||
|
||||
var src = rand.NewSource(time.Now().UnixNano())
|
||||
|
||||
func generateRandomString() string {
|
||||
b := make([]byte, randomStringLength)
|
||||
for i, cache, remain := randomStringLength-1, src.Int63(), letterIdxMax; i >= 0; {
|
||||
if remain == 0 {
|
||||
cache, remain = src.Int63(), letterIdxMax
|
||||
}
|
||||
if idx := int(cache & letterIdxMask); idx < len(letterBytes) {
|
||||
b[i] = letterBytes[idx]
|
||||
i--
|
||||
}
|
||||
cache >>= letterIdxBits
|
||||
remain--
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"path"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var paths = map[string]map[string]string{
|
||||
"/../a/b/../././/c": {
|
||||
"preserve_all": "/../a/b/../././/c",
|
||||
"preserve_protocol": "/a/c",
|
||||
"preserve_slashes": "/a//c",
|
||||
"preserve_dots": "/../a/b/../././c",
|
||||
"clean_all": "/a/c",
|
||||
},
|
||||
"/path/https://www.google.com": {
|
||||
"preserve_all": "/path/https://www.google.com",
|
||||
"preserve_protocol": "/path/https://www.google.com",
|
||||
"preserve_slashes": "/path/https://www.google.com",
|
||||
"preserve_dots": "/path/https:/www.google.com",
|
||||
"clean_all": "/path/https:/www.google.com",
|
||||
},
|
||||
"/a/b/../././/c/http://example.com/foo//bar/../blah": {
|
||||
"preserve_all": "/a/b/../././/c/http://example.com/foo//bar/../blah",
|
||||
"preserve_protocol": "/a/c/http://example.com/foo/blah",
|
||||
"preserve_slashes": "/a//c/http://example.com/foo/blah",
|
||||
"preserve_dots": "/a/b/../././c/http:/example.com/foo/bar/../blah",
|
||||
"clean_all": "/a/c/http:/example.com/foo/blah",
|
||||
},
|
||||
}
|
||||
|
||||
func assertEqual(t *testing.T, expected, received string) {
|
||||
if expected != received {
|
||||
t.Errorf("\tExpected: %s\n\t\t\tReceived: %s", expected, received)
|
||||
}
|
||||
}
|
||||
|
||||
func maskedTestRunner(t *testing.T, variation string, masks ...string) {
|
||||
for reqPath, transformation := range paths {
|
||||
assertEqual(t, transformation[variation], CleanMaskedPath(reqPath, masks...))
|
||||
}
|
||||
}
|
||||
|
||||
// No need to test the built-in path.Clean() function.
|
||||
// However, it could be useful to cross-examine the test dataset.
|
||||
func TestPathClean(t *testing.T) {
|
||||
for reqPath, transformation := range paths {
|
||||
assertEqual(t, transformation["clean_all"], path.Clean(reqPath))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanAll(t *testing.T) {
|
||||
maskedTestRunner(t, "clean_all")
|
||||
}
|
||||
|
||||
func TestPreserveAll(t *testing.T) {
|
||||
maskedTestRunner(t, "preserve_all", "//", "/..", "/.")
|
||||
}
|
||||
|
||||
func TestPreserveProtocol(t *testing.T) {
|
||||
maskedTestRunner(t, "preserve_protocol", "://")
|
||||
}
|
||||
|
||||
func TestPreserveSlashes(t *testing.T) {
|
||||
maskedTestRunner(t, "preserve_slashes", "//")
|
||||
}
|
||||
|
||||
func TestPreserveDots(t *testing.T) {
|
||||
maskedTestRunner(t, "preserve_dots", "/..", "/.")
|
||||
}
|
||||
|
||||
func TestDefaultMask(t *testing.T) {
|
||||
for reqPath, transformation := range paths {
|
||||
assertEqual(t, transformation["preserve_protocol"], CleanPath(reqPath))
|
||||
}
|
||||
}
|
||||
|
||||
func maskedBenchmarkRunner(b *testing.B, masks ...string) {
|
||||
for n := 0; n < b.N; n++ {
|
||||
for reqPath := range paths {
|
||||
CleanMaskedPath(reqPath, masks...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkPathClean(b *testing.B) {
|
||||
for n := 0; n < b.N; n++ {
|
||||
for reqPath := range paths {
|
||||
path.Clean(reqPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCleanAll(b *testing.B) {
|
||||
maskedBenchmarkRunner(b)
|
||||
}
|
||||
|
||||
func BenchmarkPreserveAll(b *testing.B) {
|
||||
maskedBenchmarkRunner(b, "//", "/..", "/.")
|
||||
}
|
||||
|
||||
func BenchmarkPreserveProtocol(b *testing.B) {
|
||||
maskedBenchmarkRunner(b, "://")
|
||||
}
|
||||
|
||||
func BenchmarkPreserveSlashes(b *testing.B) {
|
||||
maskedBenchmarkRunner(b, "//")
|
||||
}
|
||||
|
||||
func BenchmarkPreserveDots(b *testing.B) {
|
||||
maskedBenchmarkRunner(b, "/..", "/.")
|
||||
}
|
||||
|
||||
func BenchmarkDefaultMask(b *testing.B) {
|
||||
for n := 0; n < b.N; n++ {
|
||||
for reqPath := range paths {
|
||||
CleanPath(reqPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -238,37 +238,24 @@ func (r *replacer) getSubstitution(key string) string {
|
||||
}
|
||||
return host
|
||||
case "{path}":
|
||||
// if a rewrite has happened, the original URI should be used as the path
|
||||
// rather than the rewritten URI
|
||||
var path string
|
||||
origpath, _ := r.request.Context().Value(URIxRewriteCtxKey).(string)
|
||||
if origpath == "" {
|
||||
path = r.request.URL.Path
|
||||
} else {
|
||||
parsedURL, _ := url.Parse(origpath)
|
||||
path = parsedURL.Path
|
||||
}
|
||||
return path
|
||||
u, _ := r.request.Context().Value(OriginalURLCtxKey).(url.URL)
|
||||
return u.Path
|
||||
case "{path_escaped}":
|
||||
var path string
|
||||
origpath, _ := r.request.Context().Value(URIxRewriteCtxKey).(string)
|
||||
if origpath == "" {
|
||||
path = r.request.URL.Path
|
||||
} else {
|
||||
parsedURL, _ := url.Parse(origpath)
|
||||
path = parsedURL.Path
|
||||
}
|
||||
return url.QueryEscape(path)
|
||||
u, _ := r.request.Context().Value(OriginalURLCtxKey).(url.URL)
|
||||
return url.QueryEscape(u.Path)
|
||||
case "{rewrite_path}":
|
||||
return r.request.URL.Path
|
||||
case "{rewrite_path_escaped}":
|
||||
return url.QueryEscape(r.request.URL.Path)
|
||||
case "{query}":
|
||||
return r.request.URL.RawQuery
|
||||
u, _ := r.request.Context().Value(OriginalURLCtxKey).(url.URL)
|
||||
return u.RawQuery
|
||||
case "{query_escaped}":
|
||||
return url.QueryEscape(r.request.URL.RawQuery)
|
||||
u, _ := r.request.Context().Value(OriginalURLCtxKey).(url.URL)
|
||||
return url.QueryEscape(u.RawQuery)
|
||||
case "{fragment}":
|
||||
return r.request.URL.Fragment
|
||||
u, _ := r.request.Context().Value(OriginalURLCtxKey).(url.URL)
|
||||
return u.Fragment
|
||||
case "{proto}":
|
||||
return r.request.Proto
|
||||
case "{remote}":
|
||||
@@ -284,17 +271,11 @@ func (r *replacer) getSubstitution(key string) string {
|
||||
}
|
||||
return port
|
||||
case "{uri}":
|
||||
uri, _ := r.request.Context().Value(URIxRewriteCtxKey).(string)
|
||||
if uri == "" {
|
||||
uri = r.request.URL.RequestURI()
|
||||
}
|
||||
return uri
|
||||
u, _ := r.request.Context().Value(OriginalURLCtxKey).(url.URL)
|
||||
return u.RequestURI()
|
||||
case "{uri_escaped}":
|
||||
uri, _ := r.request.Context().Value(URIxRewriteCtxKey).(string)
|
||||
if uri == "" {
|
||||
uri = r.request.URL.RequestURI()
|
||||
}
|
||||
return url.QueryEscape(uri)
|
||||
u, _ := r.request.Context().Value(OriginalURLCtxKey).(url.URL)
|
||||
return url.QueryEscape(u.RequestURI())
|
||||
case "{rewrite_uri}":
|
||||
return r.request.URL.RequestURI()
|
||||
case "{rewrite_uri_escaped}":
|
||||
|
||||
@@ -41,8 +41,11 @@ func TestReplace(t *testing.T) {
|
||||
|
||||
request, err := http.NewRequest("POST", "http://localhost/?foo=bar", reader)
|
||||
if err != nil {
|
||||
t.Fatal("Request Formation Failed\n")
|
||||
t.Fatalf("Failed to make request: %v", err)
|
||||
}
|
||||
ctx := context.WithValue(request.Context(), OriginalURLCtxKey, *request.URL)
|
||||
request = request.WithContext(ctx)
|
||||
|
||||
request.Header.Set("Custom", "foobarbaz")
|
||||
request.Header.Set("ShorterVal", "1")
|
||||
repl := NewReplacer(request, recordRequest, "-")
|
||||
@@ -52,7 +55,7 @@ func TestReplace(t *testing.T) {
|
||||
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
t.Fatal("Failed to determine hostname\n")
|
||||
t.Fatalf("Failed to determine hostname: %v", err)
|
||||
}
|
||||
|
||||
old := now
|
||||
@@ -161,25 +164,26 @@ func TestPathRewrite(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Request Formation Failed: %s\n", err.Error())
|
||||
}
|
||||
|
||||
ctx := context.WithValue(request.Context(), URIxRewriteCtxKey, "a/custom/path.php?key=value")
|
||||
urlCopy := *request.URL
|
||||
urlCopy.Path = "a/custom/path.php"
|
||||
ctx := context.WithValue(request.Context(), OriginalURLCtxKey, urlCopy)
|
||||
request = request.WithContext(ctx)
|
||||
|
||||
repl := NewReplacer(request, recordRequest, "")
|
||||
|
||||
if repl.Replace("This path is '{path}'") != "This path is 'a/custom/path.php'" {
|
||||
t.Error("Expected host {path} replacement failed (" + repl.Replace("This path is '{path}'") + ")")
|
||||
if got, want := repl.Replace("This path is '{path}'"), "This path is 'a/custom/path.php'"; got != want {
|
||||
t.Errorf("{path} replacement failed; got '%s', want '%s'", got, want)
|
||||
}
|
||||
|
||||
if repl.Replace("This path is {rewrite_path}") != "This path is /index.php" {
|
||||
t.Error("Expected host {rewrite_path} replacement failed (" + repl.Replace("This path is {rewrite_path}") + ")")
|
||||
if got, want := repl.Replace("This path is {rewrite_path}"), "This path is /index.php"; got != want {
|
||||
t.Errorf("{rewrite_path} replacement failed; got '%s', want '%s'", got, want)
|
||||
}
|
||||
if repl.Replace("This path is '{uri}'") != "This path is 'a/custom/path.php?key=value'" {
|
||||
t.Error("Expected host {uri} replacement failed (" + repl.Replace("This path is '{uri}'") + ")")
|
||||
if got, want := repl.Replace("This path is '{uri}'"), "This path is 'a/custom/path.php?key=value'"; got != want {
|
||||
t.Errorf("{uri} replacement failed; got '%s', want '%s'", got, want)
|
||||
}
|
||||
|
||||
if repl.Replace("This path is {rewrite_uri}") != "This path is /index.php?key=value" {
|
||||
t.Error("Expected host {rewrite_uri} replacement failed (" + repl.Replace("This path is {rewrite_uri}") + ")")
|
||||
if got, want := repl.Replace("This path is {rewrite_uri}"), "This path is /index.php?key=value"; got != want {
|
||||
t.Errorf("{rewrite_uri} replacement failed; got '%s', want '%s'", got, want)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -290,11 +293,18 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}()
|
||||
|
||||
w.Header().Set("Server", "Caddy")
|
||||
c := context.WithValue(r.Context(), staticfiles.URLPathCtxKey, r.URL.Path)
|
||||
// copy the original, unchanged URL into the context
|
||||
// so it can be referenced by middlewares
|
||||
urlCopy := *r.URL
|
||||
if r.URL.User != nil {
|
||||
userInfo := new(url.Userinfo)
|
||||
*userInfo = *r.URL.User
|
||||
urlCopy.User = userInfo
|
||||
}
|
||||
c := context.WithValue(r.Context(), OriginalURLCtxKey, urlCopy)
|
||||
r = r.WithContext(c)
|
||||
|
||||
sanitizePath(r)
|
||||
w.Header().Set("Server", "Caddy")
|
||||
|
||||
status, _ := s.serveHTTP(w, r)
|
||||
|
||||
@@ -353,6 +363,7 @@ func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) (int, error)
|
||||
// The error returned by MaxBytesReader is meant to be handled
|
||||
// by whichever middleware/plugin that receives it when calling
|
||||
// .Read() or a similar method on the request body
|
||||
// TODO: Make this middleware instead?
|
||||
if r.Body != nil {
|
||||
for _, pathlimit := range vhost.MaxRequestBodySizes {
|
||||
if Path(r.URL.Path).Matches(pathlimit.Path) {
|
||||
@@ -407,28 +418,6 @@ func (s *Server) Stop() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// sanitizePath collapses any ./ ../ /// madness which helps prevent
|
||||
// path traversal attacks. Note to middleware: use the value within the
|
||||
// request's context at key caddy.URLPathContextKey to access the
|
||||
// "original" URL.Path value.
|
||||
func sanitizePath(r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
return
|
||||
}
|
||||
cleanedPath := CleanPath(r.URL.Path)
|
||||
if cleanedPath == "." {
|
||||
r.URL.Path = "/"
|
||||
} else {
|
||||
if !strings.HasPrefix(cleanedPath, "/") {
|
||||
cleanedPath = "/" + cleanedPath
|
||||
}
|
||||
if strings.HasSuffix(r.URL.Path, "/") && !strings.HasSuffix(cleanedPath, "/") {
|
||||
cleanedPath = cleanedPath + "/"
|
||||
}
|
||||
r.URL.Path = cleanedPath
|
||||
}
|
||||
}
|
||||
|
||||
// OnStartupComplete lists the sites served by this server
|
||||
// and any relevant information, assuming caddy.Quiet == false.
|
||||
func (s *Server) OnStartupComplete() {
|
||||
@@ -558,3 +547,20 @@ func WriteTextResponse(w http.ResponseWriter, status int, body string) {
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(body))
|
||||
}
|
||||
|
||||
// SafePath joins siteRoot and reqPath and converts it to a path that can
|
||||
// be used to access a path on the local disk. It ensures the path does
|
||||
// not traverse outside of the site root.
|
||||
//
|
||||
// If opening a file, use http.Dir instead.
|
||||
func SafePath(siteRoot, reqPath string) string {
|
||||
reqPath = filepath.ToSlash(reqPath)
|
||||
reqPath = strings.Replace(reqPath, "\x00", "", -1) // NOTE: Go 1.9 checks for null bytes in the syscall package
|
||||
if siteRoot == "" {
|
||||
siteRoot = "."
|
||||
}
|
||||
return filepath.Join(siteRoot, filepath.FromSlash(path.Clean("/"+reqPath)))
|
||||
}
|
||||
|
||||
// OriginalURLCtxKey is the key for accessing the original, incoming URL on an HTTP request.
|
||||
const OriginalURLCtxKey = caddy.CtxKey("original_url")
|
||||
|
||||
Reference in New Issue
Block a user