Skip to content

Commit

Permalink
caddyhttp: Refactor and export SanitizedPathJoin for use in fastcgi (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
mholt authored Jun 17, 2021
1 parent fbd6560 commit 9d4ed3a
Show file tree
Hide file tree
Showing 8 changed files with 131 additions and 134 deletions.
29 changes: 29 additions & 0 deletions modules/caddyhttp/caddyhttp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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 changes: 94 additions & 0 deletions modules/caddyhttp/caddyhttp_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
2 changes: 1 addition & 1 deletion modules/caddyhttp/fileserver/browse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion modules/caddyhttp/fileserver/matcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
4 changes: 2 additions & 2 deletions modules/caddyhttp/fileserver/matcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down
40 changes: 2 additions & 38 deletions modules/caddyhttp/fileserver/staticfiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand Down
84 changes: 0 additions & 84 deletions modules/caddyhttp/fileserver/staticfiles_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 2 additions & 8 deletions modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import (
"fmt"
"net"
"net/http"
"path"
"path/filepath"
"strconv"
"strings"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 9d4ed3a

Please sign in to comment.