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
+}