@marcel91200 / caddy Mirror · read-only / commits / aaacab1b
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 14, 05:15 UTC

Sanitize paths in static file server; some cleanup

Also remove AutomaticHTTPSError for now

Matthew Holt committed May 20, 2019 at 17:15 UTC aaacab1bc3790e3a207ae5d70ca6559cac265bff
3 files changed +159 -50
modules/caddyhttp/caddyhttp.go
+3 -13
@@ -95,21 +95,11 @@ func (app *App) Validate() error {
95 return nil
96 }
97
98 -// AutomaticHTTPSError represents an error received when attempting to enable automatic https.
99 -type AutomaticHTTPSError struct {
100 - internal error
101 -}
102 -
103 -// Error returns the error string for automaticHTTPSError.
104 -func (a AutomaticHTTPSError) Error() string {
105 - return fmt.Sprintf("enabling automatic HTTPS: %v", a.internal.Error())
106 -}
107 -
98 // Start runs the app. It sets up automatic HTTPS if enabled.
99 func (app *App) Start() error {
100 err := app.automaticHTTPS()
101 if err != nil {
112 - return &AutomaticHTTPSError{internal: err}
102 + return fmt.Errorf("enabling automatic HTTPS: %v", err)
103 }
104
105 for srvName, srv := range app.Servers {
@@ -243,7 +233,7 @@ func (app *App) automaticHTTPS() error {
233 if parts := strings.SplitN(port, "-", 2); len(parts) == 2 {
234 port = parts[0]
235 }
246 - redirTo := "https://{request.host}"
236 + redirTo := "https://{http.request.host}"
237
238 httpsPort := app.HTTPSPort
239 if httpsPort == 0 {
@@ -252,7 +242,7 @@ func (app *App) automaticHTTPS() error {
242 if port != strconv.Itoa(httpsPort) {
243 redirTo += ":" + port
244 }
255 - redirTo += "{request.uri}"
245 + redirTo += "{http.request.uri}"
246
247 redirRoutes = append(redirRoutes, ServerRoute{
248 matchers: []RequestMatcher{
modules/caddyhttp/staticfiles/staticfiles.go
+78 -37
@@ -72,14 +72,21 @@ func (sf *StaticFiles) Provision(ctx caddy2.Context) error {
72 return nil
73 }
74
75 +const (
76 + selectionPolicyFirstExisting = "first_existing"
77 + selectionPolicyLargestSize = "largest_size"
78 + selectionPolicySmallestSize = "smallest_size"
79 + selectionPolicyRecentlyMod = "most_recently_modified"
80 +)
81 +
82 // Validate ensures that sf has a valid configuration.
83 func (sf *StaticFiles) Validate() error {
84 switch sf.SelectionPolicy {
85 case "",
79 - "first_existing",
80 - "largest_size",
81 - "smallest_size",
82 - "most_recently_modified":
86 + selectionPolicyFirstExisting,
87 + selectionPolicyLargestSize,
88 + selectionPolicySmallestSize,
89 + selectionPolicyRecentlyMod:
90 default:
91 return fmt.Errorf("unknown selection policy %s", sf.SelectionPolicy)
92 }
@@ -87,15 +94,6 @@ func (sf *StaticFiles) Validate() error {
94 }
95
96 func (sf *StaticFiles) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
90 - // TODO: Prevent directory traversal, see https://play.golang.org/p/oh77BiVQFti
91 -
92 - // TODO: Still needed?
93 - // // Prevent absolute path access on Windows, e.g.: http://localhost:5000/C:\Windows\notepad.exe
94 - // // TODO: does stdlib http.Dir handle this? see first check of http.Dir.Open()...
95 - // if runtime.GOOS == "windows" && len(reqPath) > 0 && filepath.IsAbs(reqPath[1:]) {
96 - // return caddyhttp.Error(http.StatusNotFound, fmt.Errorf("request path was absolute"))
97 - // }
98 -
97 repl := r.Context().Value(caddy2.ReplacerCtxKey).(caddy2.Replacer)
98
99 // map the request to a filename
@@ -113,7 +111,6 @@ func (sf *StaticFiles) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
111 // if the ultimate destination has changed, submit
112 // this request for a rehandling (internal redirect)
113 // if configured to do so
116 - // TODO: double check this against https://docs.nginx.com/nginx/admin-guide/web-server/serving-static-content/
114 if r.URL.Path != pathBefore && sf.Rehandle {
115 return caddyhttp.ErrRehandle
116 }
@@ -121,6 +118,7 @@ func (sf *StaticFiles) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
118 // get information about the file
119 info, err := os.Stat(filename)
120 if err != nil {
121 + err = mapDirOpenError(err, filename)
122 if os.IsNotExist(err) {
123 return caddyhttp.Error(http.StatusNotFound, err)
124 } else if os.IsPermission(err) {
@@ -136,7 +134,7 @@ func (sf *StaticFiles) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
134 filesToHide := sf.transformHidePaths(repl)
135
136 for _, indexPage := range sf.IndexNames {
139 - indexPath := path.Join(filename, indexPage)
137 + indexPath := sanitizedPathJoin(filename, indexPage)
138 if fileHidden(indexPath, filesToHide) {
139 // pretend this file doesn't exist
140 continue
@@ -150,8 +148,6 @@ func (sf *StaticFiles) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
148 // we found an index file that might work,
149 // so rewrite the request path and, if
150 // configured, do an internal redirect
153 - // TODO: I don't know if the logic for rewriting
154 - // the URL here is the right logic
151 r.URL.Path = path.Join(r.URL.Path, indexPage)
152 if sf.Rehandle {
153 return caddyhttp.ErrRehandle
@@ -172,6 +168,8 @@ func (sf *StaticFiles) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
168 return caddyhttp.Error(http.StatusNotFound, nil)
169 }
170
171 + // TODO: content negotiation (brotli sidecar files, etc...)
172 +
173 // open the file
174 file, err := sf.openFile(filename, w)
175 if err != nil {
@@ -179,9 +177,7 @@ func (sf *StaticFiles) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
177 }
178 defer file.Close()
179
182 - // TODO: Etag?
183 -
184 - // TODO: content negotiation? (brotli sidecar files, etc...)
180 + // TODO: Etag
181
182 // let the standard library do what it does best; note, however,
183 // that errors generated by ServeContent are written immediately
@@ -199,6 +195,7 @@ func (sf *StaticFiles) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
195 func (sf *StaticFiles) openFile(filename string, w http.ResponseWriter) (*os.File, error) {
196 file, err := os.Open(filename)
197 if err != nil {
198 + err = mapDirOpenError(err, filename)
199 if os.IsNotExist(err) {
200 return nil, caddyhttp.Error(http.StatusNotFound, err)
201 } else if os.IsPermission(err) {
@@ -213,6 +210,34 @@ func (sf *StaticFiles) openFile(filename string, w http.ResponseWriter) (*os.Fil
210 return file, nil
211 }
212
213 +// mapDirOpenError maps the provided non-nil error from opening name
214 +// to a possibly better non-nil error. In particular, it turns OS-specific errors
215 +// about opening files in non-directories into os.ErrNotExist. See golang/go#18984.
216 +// Adapted from the Go standard library; originally written by Nathaniel Caza.
217 +// https://go-review.googlesource.com/c/go/+/36635/
218 +// https://go-review.googlesource.com/c/go/+/36804/
219 +func mapDirOpenError(originalErr error, name string) error {
220 + if os.IsNotExist(originalErr) || os.IsPermission(originalErr) {
221 + return originalErr
222 + }
223 +
224 + parts := strings.Split(name, string(filepath.Separator))
225 + for i := range parts {
226 + if parts[i] == "" {
227 + continue
228 + }
229 + fi, err := os.Stat(strings.Join(parts[:i+1], string(filepath.Separator)))
230 + if err != nil {
231 + return originalErr
232 + }
233 + if !fi.IsDir() {
234 + return os.ErrNotExist
235 + }
236 + }
237 +
238 + return originalErr
239 +}
240 +
241 // transformHidePaths performs replacements for all the elements of
242 // sf.Hide and returns a new list of the transformed values.
243 func (sf *StaticFiles) transformHidePaths(repl caddy2.Replacer) []string {
@@ -223,42 +248,60 @@ func (sf *StaticFiles) transformHidePaths(repl caddy2.Replacer) []string {
248 return hide
249 }
250
251 +// sanitizedPathJoin performs filepath.Join(root, reqPath) that
252 +// is safe against directory traversal attacks. It uses logic
253 +// similar to that in the Go standard library, specifically
254 +// in the implementation of http.Dir.
255 +func sanitizedPathJoin(root, reqPath string) string {
256 + // TODO: Caddy 1 uses this:
257 + // prevent absolute path access on Windows, e.g. http://localhost:5000/C:\Windows\notepad.exe
258 + // if runtime.GOOS == "windows" && len(reqPath) > 0 && filepath.IsAbs(reqPath[1:]) {
259 + // TODO.
260 + // }
261 +
262 + // TODO: whereas std lib's http.Dir.Open() uses this:
263 + // if filepath.Separator != '/' && strings.ContainsRune(name, filepath.Separator) {
264 + // return nil, errors.New("http: invalid character in file path")
265 + // }
266 +
267 + // TODO: see https://play.golang.org/p/oh77BiVQFti for another thing to consider
268 +
269 + if root == "" {
270 + root = "."
271 + }
272 + return filepath.Join(root, filepath.FromSlash(path.Clean("/"+reqPath)))
273 +}
274 +
275 // selectFile uses the specified selection policy (or first_existing
276 // by default) to map the request r to a filename. The full path to
277 // the file is returned if one is found; otherwise, an empty string
278 // is returned.
279 func (sf *StaticFiles) selectFile(r *http.Request, repl caddy2.Replacer) string {
280 root := repl.ReplaceAll(sf.Root, "")
232 - if root == "" {
233 - root = "."
234 - }
281
282 if sf.Files == nil {
237 - return filepath.Join(root, r.URL.Path)
283 + return sanitizedPathJoin(root, r.URL.Path)
284 }
285
286 switch sf.SelectionPolicy {
241 - // TODO: Make these policy names constants
242 - case "", "first_existing":
287 + case "", selectionPolicyFirstExisting:
288 filesToHide := sf.transformHidePaths(repl)
289 for _, f := range sf.Files {
290 suffix := repl.ReplaceAll(f, "")
246 - // TODO: sanitize path
247 - fullpath := filepath.Join(root, suffix)
291 + fullpath := sanitizedPathJoin(root, suffix)
292 if !fileHidden(fullpath, filesToHide) && fileExists(fullpath) {
293 r.URL.Path = suffix
294 return fullpath
295 }
296 }
297
254 - case "largest_size":
298 + case selectionPolicyLargestSize:
299 var largestSize int64
300 var largestFilename string
301 var largestSuffix string
302 for _, f := range sf.Files {
303 suffix := repl.ReplaceAll(f, "")
260 - // TODO: sanitize path
261 - fullpath := filepath.Join(root, suffix)
304 + fullpath := sanitizedPathJoin(root, suffix)
305 info, err := os.Stat(fullpath)
306 if err == nil && info.Size() > largestSize {
307 largestSize = info.Size()
@@ -269,14 +312,13 @@ func (sf *StaticFiles) selectFile(r *http.Request, repl caddy2.Replacer) string
312 r.URL.Path = largestSuffix
313 return largestFilename
314
272 - case "smallest_size":
315 + case selectionPolicySmallestSize:
316 var smallestSize int64
317 var smallestFilename string
318 var smallestSuffix string
319 for _, f := range sf.Files {
320 suffix := repl.ReplaceAll(f, "")
278 - // TODO: sanitize path
279 - fullpath := filepath.Join(root, suffix)
321 + fullpath := sanitizedPathJoin(root, suffix)
322 info, err := os.Stat(fullpath)
323 if err == nil && (smallestSize == 0 || info.Size() < smallestSize) {
324 smallestSize = info.Size()
@@ -287,14 +329,13 @@ func (sf *StaticFiles) selectFile(r *http.Request, repl caddy2.Replacer) string
329 r.URL.Path = smallestSuffix
330 return smallestFilename
331
290 - case "most_recently_modified":
332 + case selectionPolicyRecentlyMod:
333 var recentDate time.Time
334 var recentFilename string
335 var recentSuffix string
336 for _, f := range sf.Files {
337 suffix := repl.ReplaceAll(f, "")
296 - // TODO: sanitize path
297 - fullpath := filepath.Join(root, suffix)
338 + fullpath := sanitizedPathJoin(root, suffix)
339 info, err := os.Stat(fullpath)
340 if err == nil &&
341 (recentDate.IsZero() || info.ModTime().After(recentDate)) {
modules/caddyhttp/staticfiles/staticfiles_test.go new
+78
@@ -0,0 +1,78 @@
1 +package staticfiles
2 +
3 +import (
4 + "net/url"
5 + "testing"
6 +)
7 +
8 +func TestSanitizedPathJoin(t *testing.T) {
9 + // For easy reference:
10 + // %2E = .
11 + // %2F = /
12 + // %5C = \
13 + for i, tc := range []struct {
14 + inputRoot string
15 + inputPath string
16 + expect string
17 + }{
18 + {
19 + inputPath: "",
20 + expect: ".",
21 + },
22 + {
23 + inputPath: "/",
24 + expect: ".",
25 + },
26 + {
27 + inputPath: "/foo",
28 + expect: "foo",
29 + },
30 + {
31 + inputPath: "/foo/bar",
32 + expect: "foo/bar",
33 + },
34 + {
35 + inputRoot: "/a",
36 + inputPath: "/foo/bar",
37 + expect: "/a/foo/bar",
38 + },
39 + {
40 + inputPath: "/foo/../bar",
41 + expect: "bar",
42 + },
43 + {
44 + inputRoot: "/a/b",
45 + inputPath: "/foo/../bar",
46 + expect: "/a/b/bar",
47 + },
48 + {
49 + inputRoot: "/a/b",
50 + inputPath: "/..%2fbar",
51 + expect: "/a/b/bar",
52 + },
53 + {
54 + inputRoot: "/a/b",
55 + inputPath: "/%2e%2e%2fbar",
56 + expect: "/a/b/bar",
57 + },
58 + {
59 + inputRoot: "/a/b",
60 + inputPath: "/%2e%2e%2f%2e%2e%2f",
61 + expect: "/a/b",
62 + },
63 + } {
64 + // we don't *need* to use an actual parsed URL, but it
65 + // adds some authenticity to the tests since real-world
66 + // values will be coming in from URLs; thus, the test
67 + // corpus can contain paths as encoded by clients, which
68 + // more closely emulates the actual attack vector
69 + u, err := url.Parse("http://test:9999" + tc.inputPath)
70 + if err != nil {
71 + t.Fatalf("Test %d: invalid URL: %v", i, err)
72 + }
73 + actual := sanitizedPathJoin(tc.inputRoot, u.Path)
74 + if actual != tc.expect {
75 + t.Errorf("Test %d: [%s %s] => %s (expected %s)", i, tc.inputRoot, tc.inputPath, actual, tc.expect)
76 + }
77 + }
78 +}