mirror of
				https://github.com/caddyserver/caddy.git
				synced 2025-11-03 19:17:29 -05:00 
			
		
		
		
	caddyhttp: Refactor and export SanitizedPathJoin for use in fastcgi (#4207)
This commit is contained in:
		
							parent
							
								
									fbd6560976
								
							
						
					
					
						commit
						9d4ed3a323
					
				@ -20,7 +20,9 @@ import (
 | 
			
		||||
	"io"
 | 
			
		||||
	"net"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"strconv"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"github.com/caddyserver/caddy/v2"
 | 
			
		||||
	"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
 | 
			
		||||
@ -217,6 +219,31 @@ func StatusCodeMatches(actual, configured int) bool {
 | 
			
		||||
	return false
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// SanitizedPathJoin performs filepath.Join(root, reqPath) that
 | 
			
		||||
// is safe against directory traversal attacks. It uses logic
 | 
			
		||||
// similar to that in the Go standard library, specifically
 | 
			
		||||
// in the implementation of http.Dir. The root is assumed to
 | 
			
		||||
// be a trusted path, but reqPath is not; and the output will
 | 
			
		||||
// never be outside of root. The resulting path can be used
 | 
			
		||||
// with the local file system.
 | 
			
		||||
func SanitizedPathJoin(root, reqPath string) string {
 | 
			
		||||
	if root == "" {
 | 
			
		||||
		root = "."
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	path := filepath.Join(root, filepath.Clean("/"+reqPath))
 | 
			
		||||
 | 
			
		||||
	// filepath.Join also cleans the path, and cleaning strips
 | 
			
		||||
	// the trailing slash, so we need to re-add it afterwards.
 | 
			
		||||
	// if the length is 1, then it's a path to the root,
 | 
			
		||||
	// and that should return ".", so we don't append the separator.
 | 
			
		||||
	if strings.HasSuffix(reqPath, "/") && len(reqPath) > 1 {
 | 
			
		||||
		path += separator
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return path
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// tlsPlaceholderWrapper is a no-op listener wrapper that marks
 | 
			
		||||
// where the TLS listener should be in a chain of listener wrappers.
 | 
			
		||||
// It should only be used if another listener wrapper must be placed
 | 
			
		||||
@ -242,6 +269,8 @@ const (
 | 
			
		||||
	DefaultHTTPSPort = 443
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
const separator = string(filepath.Separator)
 | 
			
		||||
 | 
			
		||||
// Interface guard
 | 
			
		||||
var _ caddy.ListenerWrapper = (*tlsPlaceholderWrapper)(nil)
 | 
			
		||||
var _ caddyfile.Unmarshaler = (*tlsPlaceholderWrapper)(nil)
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										94
									
								
								modules/caddyhttp/caddyhttp_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								modules/caddyhttp/caddyhttp_test.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,94 @@
 | 
			
		||||
package caddyhttp
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"net/url"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"testing"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func TestSanitizedPathJoin(t *testing.T) {
 | 
			
		||||
	// For reference:
 | 
			
		||||
	// %2e = .
 | 
			
		||||
	// %2f = /
 | 
			
		||||
	// %5c = \
 | 
			
		||||
	for i, tc := range []struct {
 | 
			
		||||
		inputRoot string
 | 
			
		||||
		inputPath string
 | 
			
		||||
		expect    string
 | 
			
		||||
	}{
 | 
			
		||||
		{
 | 
			
		||||
			inputPath: "",
 | 
			
		||||
			expect:    ".",
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			inputPath: "/",
 | 
			
		||||
			expect:    ".",
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			inputPath: "/foo",
 | 
			
		||||
			expect:    "foo",
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			inputPath: "/foo/",
 | 
			
		||||
			expect:    "foo" + separator,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			inputPath: "/foo/bar",
 | 
			
		||||
			expect:    filepath.Join("foo", "bar"),
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			inputRoot: "/a",
 | 
			
		||||
			inputPath: "/foo/bar",
 | 
			
		||||
			expect:    filepath.Join("/", "a", "foo", "bar"),
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			inputPath: "/foo/../bar",
 | 
			
		||||
			expect:    "bar",
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			inputRoot: "/a/b",
 | 
			
		||||
			inputPath: "/foo/../bar",
 | 
			
		||||
			expect:    filepath.Join("/", "a", "b", "bar"),
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			inputRoot: "/a/b",
 | 
			
		||||
			inputPath: "/..%2fbar",
 | 
			
		||||
			expect:    filepath.Join("/", "a", "b", "bar"),
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			inputRoot: "/a/b",
 | 
			
		||||
			inputPath: "/%2e%2e%2fbar",
 | 
			
		||||
			expect:    filepath.Join("/", "a", "b", "bar"),
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			inputRoot: "/a/b",
 | 
			
		||||
			inputPath: "/%2e%2e%2f%2e%2e%2f",
 | 
			
		||||
			expect:    filepath.Join("/", "a", "b") + separator,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			inputRoot: "C:\\www",
 | 
			
		||||
			inputPath: "/foo/bar",
 | 
			
		||||
			expect:    filepath.Join("C:\\www", "foo", "bar"),
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			inputRoot: "C:\\www",
 | 
			
		||||
			inputPath: "/D:\\foo\\bar",
 | 
			
		||||
			expect:    filepath.Join("C:\\www", "D:\\foo\\bar"),
 | 
			
		||||
		},
 | 
			
		||||
	} {
 | 
			
		||||
		// we don't *need* to use an actual parsed URL, but it
 | 
			
		||||
		// adds some authenticity to the tests since real-world
 | 
			
		||||
		// values will be coming in from URLs; thus, the test
 | 
			
		||||
		// corpus can contain paths as encoded by clients, which
 | 
			
		||||
		// more closely emulates the actual attack vector
 | 
			
		||||
		u, err := url.Parse("http://test:9999" + tc.inputPath)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Test %d: invalid URL: %v", i, err)
 | 
			
		||||
		}
 | 
			
		||||
		actual := SanitizedPathJoin(tc.inputRoot, u.Path)
 | 
			
		||||
		if actual != tc.expect {
 | 
			
		||||
			t.Errorf("Test %d: SanitizedPathJoin('%s', '%s') =>  %s (expected '%s')",
 | 
			
		||||
				i, tc.inputRoot, tc.inputPath, actual, tc.expect)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
@ -207,7 +207,7 @@ func isSymlinkTargetDir(f os.FileInfo, root, urlPath string) bool {
 | 
			
		||||
	if !isSymlink(f) {
 | 
			
		||||
		return false
 | 
			
		||||
	}
 | 
			
		||||
	target := sanitizedPathJoin(root, path.Join(urlPath, f.Name()))
 | 
			
		||||
	target := caddyhttp.SanitizedPathJoin(root, path.Join(urlPath, f.Name()))
 | 
			
		||||
	targetInfo, err := os.Stat(target)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return false
 | 
			
		||||
 | 
			
		||||
@ -185,7 +185,7 @@ func (m MatchFile) selectFile(r *http.Request) (matched bool) {
 | 
			
		||||
		if strings.HasSuffix(file, "/") {
 | 
			
		||||
			suffix += "/"
 | 
			
		||||
		}
 | 
			
		||||
		fullpath = sanitizedPathJoin(root, suffix)
 | 
			
		||||
		fullpath = caddyhttp.SanitizedPathJoin(root, suffix)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -94,7 +94,7 @@ func TestFileMatcher(t *testing.T) {
 | 
			
		||||
			t.Fatalf("Test %d: actual path: %v, expected: %v", i, rel, tc.expectedPath)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		fileType, ok := repl.Get("http.matchers.file.type")
 | 
			
		||||
		fileType, _ := repl.Get("http.matchers.file.type")
 | 
			
		||||
		if fileType != tc.expectedType {
 | 
			
		||||
			t.Fatalf("Test %d: actual file type: %v, expected: %v", i, fileType, tc.expectedType)
 | 
			
		||||
		}
 | 
			
		||||
@ -197,7 +197,7 @@ func TestPHPFileMatcher(t *testing.T) {
 | 
			
		||||
			t.Fatalf("Test %d: actual path: %v, expected: %v", i, rel, tc.expectedPath)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		fileType, ok := repl.Get("http.matchers.file.type")
 | 
			
		||||
		fileType, _ := repl.Get("http.matchers.file.type")
 | 
			
		||||
		if fileType != tc.expectedType {
 | 
			
		||||
			t.Fatalf("Test %d: actual file type: %v, expected: %v", i, fileType, tc.expectedType)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
@ -161,7 +161,7 @@ func (fsrv *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request, next c
 | 
			
		||||
	filesToHide := fsrv.transformHidePaths(repl)
 | 
			
		||||
 | 
			
		||||
	root := repl.ReplaceAll(fsrv.Root, ".")
 | 
			
		||||
	filename := sanitizedPathJoin(root, r.URL.Path)
 | 
			
		||||
	filename := caddyhttp.SanitizedPathJoin(root, r.URL.Path)
 | 
			
		||||
 | 
			
		||||
	fsrv.logger.Debug("sanitized path join",
 | 
			
		||||
		zap.String("site_root", root),
 | 
			
		||||
@ -185,7 +185,7 @@ func (fsrv *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request, next c
 | 
			
		||||
	var implicitIndexFile bool
 | 
			
		||||
	if info.IsDir() && len(fsrv.IndexNames) > 0 {
 | 
			
		||||
		for _, indexPage := range fsrv.IndexNames {
 | 
			
		||||
			indexPath := sanitizedPathJoin(filename, indexPage)
 | 
			
		||||
			indexPath := caddyhttp.SanitizedPathJoin(filename, indexPage)
 | 
			
		||||
			if fileHidden(indexPath, filesToHide) {
 | 
			
		||||
				// pretend this file doesn't exist
 | 
			
		||||
				fsrv.logger.Debug("hiding index file",
 | 
			
		||||
@ -435,42 +435,6 @@ func (fsrv *FileServer) transformHidePaths(repl *caddy.Replacer) []string {
 | 
			
		||||
	return hide
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// sanitizedPathJoin performs filepath.Join(root, reqPath) that
 | 
			
		||||
// is safe against directory traversal attacks. It uses logic
 | 
			
		||||
// similar to that in the Go standard library, specifically
 | 
			
		||||
// in the implementation of http.Dir. The root is assumed to
 | 
			
		||||
// be a trusted path, but reqPath is not.
 | 
			
		||||
func sanitizedPathJoin(root, reqPath string) string {
 | 
			
		||||
	// TODO: Caddy 1 uses this:
 | 
			
		||||
	// prevent absolute path access on Windows, e.g. http://localhost:5000/C:\Windows\notepad.exe
 | 
			
		||||
	// if runtime.GOOS == "windows" && len(reqPath) > 0 && filepath.IsAbs(reqPath[1:]) {
 | 
			
		||||
	// TODO.
 | 
			
		||||
	// }
 | 
			
		||||
 | 
			
		||||
	// TODO: whereas std lib's http.Dir.Open() uses this:
 | 
			
		||||
	// if filepath.Separator != '/' && strings.ContainsRune(name, filepath.Separator) {
 | 
			
		||||
	// 	return nil, errors.New("http: invalid character in file path")
 | 
			
		||||
	// }
 | 
			
		||||
 | 
			
		||||
	// TODO: see https://play.golang.org/p/oh77BiVQFti for another thing to consider
 | 
			
		||||
 | 
			
		||||
	if root == "" {
 | 
			
		||||
		root = "."
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	path := filepath.Join(root, filepath.Clean("/"+reqPath))
 | 
			
		||||
 | 
			
		||||
	// filepath.Join also cleans the path, and cleaning strips
 | 
			
		||||
	// the trailing slash, so we need to re-add it afterwards.
 | 
			
		||||
	// if the length is 1, then it's a path to the root,
 | 
			
		||||
	// and that should return ".", so we don't append the separator.
 | 
			
		||||
	if strings.HasSuffix(reqPath, "/") && len(reqPath) > 1 {
 | 
			
		||||
		path += separator
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return path
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// fileHidden returns true if filename is hidden according to the hide list.
 | 
			
		||||
// filename must be a relative or absolute file system path, not a request
 | 
			
		||||
// URI path. It is expected that all the paths in the hide list are absolute
 | 
			
		||||
 | 
			
		||||
@ -15,96 +15,12 @@
 | 
			
		||||
package fileserver
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"net/url"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"runtime"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"testing"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func TestSanitizedPathJoin(t *testing.T) {
 | 
			
		||||
	// For easy reference:
 | 
			
		||||
	// %2e = .
 | 
			
		||||
	// %2f = /
 | 
			
		||||
	// %5c = \
 | 
			
		||||
	for i, tc := range []struct {
 | 
			
		||||
		inputRoot string
 | 
			
		||||
		inputPath string
 | 
			
		||||
		expect    string
 | 
			
		||||
	}{
 | 
			
		||||
		{
 | 
			
		||||
			inputPath: "",
 | 
			
		||||
			expect:    ".",
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			inputPath: "/",
 | 
			
		||||
			expect:    ".",
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			inputPath: "/foo",
 | 
			
		||||
			expect:    "foo",
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			inputPath: "/foo/",
 | 
			
		||||
			expect:    "foo" + separator,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			inputPath: "/foo/bar",
 | 
			
		||||
			expect:    filepath.Join("foo", "bar"),
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			inputRoot: "/a",
 | 
			
		||||
			inputPath: "/foo/bar",
 | 
			
		||||
			expect:    filepath.Join("/", "a", "foo", "bar"),
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			inputPath: "/foo/../bar",
 | 
			
		||||
			expect:    "bar",
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			inputRoot: "/a/b",
 | 
			
		||||
			inputPath: "/foo/../bar",
 | 
			
		||||
			expect:    filepath.Join("/", "a", "b", "bar"),
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			inputRoot: "/a/b",
 | 
			
		||||
			inputPath: "/..%2fbar",
 | 
			
		||||
			expect:    filepath.Join("/", "a", "b", "bar"),
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			inputRoot: "/a/b",
 | 
			
		||||
			inputPath: "/%2e%2e%2fbar",
 | 
			
		||||
			expect:    filepath.Join("/", "a", "b", "bar"),
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			inputRoot: "/a/b",
 | 
			
		||||
			inputPath: "/%2e%2e%2f%2e%2e%2f",
 | 
			
		||||
			expect:    filepath.Join("/", "a", "b") + separator,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			inputRoot: "C:\\www",
 | 
			
		||||
			inputPath: "/foo/bar",
 | 
			
		||||
			expect:    filepath.Join("C:\\www", "foo", "bar"),
 | 
			
		||||
		},
 | 
			
		||||
		// TODO: test more windows paths... on windows... sigh.
 | 
			
		||||
	} {
 | 
			
		||||
		// we don't *need* to use an actual parsed URL, but it
 | 
			
		||||
		// adds some authenticity to the tests since real-world
 | 
			
		||||
		// values will be coming in from URLs; thus, the test
 | 
			
		||||
		// corpus can contain paths as encoded by clients, which
 | 
			
		||||
		// more closely emulates the actual attack vector
 | 
			
		||||
		u, err := url.Parse("http://test:9999" + tc.inputPath)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("Test %d: invalid URL: %v", i, err)
 | 
			
		||||
		}
 | 
			
		||||
		actual := sanitizedPathJoin(tc.inputRoot, u.Path)
 | 
			
		||||
		if actual != tc.expect {
 | 
			
		||||
			t.Errorf("Test %d: [%s %s] => %s (expected %s)",
 | 
			
		||||
				i, tc.inputRoot, tc.inputPath, actual, tc.expect)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestFileHidden(t *testing.T) {
 | 
			
		||||
	for i, tc := range []struct {
 | 
			
		||||
		inputHide []string
 | 
			
		||||
 | 
			
		||||
@ -20,7 +20,6 @@ import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"net"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"path"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"strconv"
 | 
			
		||||
	"strings"
 | 
			
		||||
@ -218,12 +217,7 @@ func (t Transport) buildEnv(r *http.Request) (map[string]string, error) {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// SCRIPT_FILENAME is the absolute path of SCRIPT_NAME
 | 
			
		||||
	scriptFilename := filepath.Join(root, scriptName)
 | 
			
		||||
 | 
			
		||||
	// Add vhost path prefix to scriptName. Otherwise, some PHP software will
 | 
			
		||||
	// have difficulty discovering its URL.
 | 
			
		||||
	pathPrefix, _ := r.Context().Value(caddy.CtxKey("path_prefix")).(string)
 | 
			
		||||
	scriptName = path.Join(pathPrefix, scriptName)
 | 
			
		||||
	scriptFilename := caddyhttp.SanitizedPathJoin(root, scriptName)
 | 
			
		||||
 | 
			
		||||
	// Ensure the SCRIPT_NAME has a leading slash for compliance with RFC3875
 | 
			
		||||
	// Info: https://tools.ietf.org/html/rfc3875#section-4.1.13
 | 
			
		||||
@ -288,7 +282,7 @@ func (t Transport) buildEnv(r *http.Request) (map[string]string, error) {
 | 
			
		||||
	// PATH_TRANSLATED should only exist if PATH_INFO is defined.
 | 
			
		||||
	// Info: https://www.ietf.org/rfc/rfc3875 Page 14
 | 
			
		||||
	if env["PATH_INFO"] != "" {
 | 
			
		||||
		env["PATH_TRANSLATED"] = filepath.Join(root, pathInfo) // Info: http://www.oreilly.com/openbook/cgi/ch02_04.html
 | 
			
		||||
		env["PATH_TRANSLATED"] = caddyhttp.SanitizedPathJoin(root, pathInfo) // Info: http://www.oreilly.com/openbook/cgi/ch02_04.html
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// compliance with the CGI specification requires that
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user