Skip to content

Commit 87d9ac5

Browse files
committed
fix: validate web serve files with filesystem
1 parent f9dea50 commit 87d9ac5

File tree

2 files changed

+133
-312
lines changed

2 files changed

+133
-312
lines changed

services/web/pkg/assets/server.go

Lines changed: 76 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,11 @@ import (
66
"io/fs"
77
"mime"
88
"net/http"
9-
"net/url"
9+
"os"
1010
"path"
1111
"path/filepath"
12-
"regexp"
13-
"strings"
14-
"unicode/utf8"
1512

1613
"golang.org/x/net/html"
17-
"golang.org/x/text/unicode/norm"
1814
)
1915

2016
type fileServer struct {
@@ -26,103 +22,113 @@ func FileServer(fsys fs.FS) http.Handler {
2622
return &fileServer{http.FS(fsys)}
2723
}
2824

29-
// IsSafePath validates if a path is safe from path traversal attacks
30-
func IsSafePath(p string) bool {
31-
var dangerousRunes = []rune{
32-
'\uFF0F', // fullwidth slash /
33-
'\u2215', // division slash ∕
34-
'\u29F8', // big solidus ⧸
35-
'\u2044', // fraction slash ⁄
36-
'\\', // backslash
37-
'\uFF3C', // fullwidth backslash \
38-
'\uFE68', // small reverse solidus ﹨
39-
}
25+
func (f *fileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
26+
path := path.Clean(path.Join("/", r.URL.Path))
4027

41-
// Recursive URL decode to prevent bypass via %252e%252e (double encoding)
42-
path := p
43-
for {
44-
decoded, err := url.QueryUnescape(path)
45-
if err != nil || decoded == path {
46-
break
28+
serveIndex := func() {
29+
// not every fs contains a file named index.html,
30+
// therefore, we need to check if the file exists
31+
indexPath := "/index.html"
32+
file, err := f.fsys.Open(indexPath)
33+
if err != nil {
34+
http.NotFound(w, r)
35+
return
4736
}
48-
path = decoded
49-
}
50-
51-
// Reject null byte injection
52-
if strings.Contains(path, "\x00") {
53-
return false
54-
}
55-
56-
// Reject invalid UTF-8
57-
if !utf8.ValidString(path) {
58-
return false
59-
}
37+
defer file.Close()
6038

61-
// Reject dangerous runes
62-
normalized := norm.NFC.String(path)
63-
for _, r := range normalized {
64-
for _, bad := range dangerousRunes {
65-
if r == bad {
66-
return false
67-
}
39+
s, err := file.Stat()
40+
if err != nil || s.IsDir() {
41+
http.NotFound(w, r)
42+
return
6843
}
69-
}
70-
71-
// match one or more /./ and /../
72-
return !regexp.MustCompile(`(?:[\/\\]\.+[\/\\])+`).MatchString(path)
73-
}
74-
75-
func (f *fileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
76-
if !IsSafePath(r.URL.Path) {
77-
http.NotFound(w, r)
78-
return
79-
}
8044

81-
uPath := path.Clean(path.Join("/", r.URL.Path))
82-
r.URL.Path = uPath
45+
w.Header().Set("Content-Type", mime.TypeByExtension(filepath.Ext(s.Name())))
46+
w.Header().Add("Vary", "Accept-Encoding")
8347

84-
tryIndex := func() {
85-
r.URL.Path = "/index.html"
86-
87-
// not every fs contains a file named index.html,
88-
// therefore, we need to check if the file exists and stop the recursion if it doesn't
89-
file, err := f.fsys.Open(r.URL.Path)
90-
if err != nil {
48+
buf := new(bytes.Buffer)
49+
w.Header().Del("Expires")
50+
w.Header().Set("Cache-Control", "no-cache")
51+
if err := withBase(buf, file, "/"); err != nil {
9152
http.NotFound(w, r)
9253
return
9354
}
94-
defer file.Close()
55+
if _, err := w.Write(buf.Bytes()); err != nil {
56+
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
57+
return
58+
}
59+
}
9560

96-
f.ServeHTTP(w, r)
61+
if !isValid(f, path) {
62+
serveIndex()
63+
return
9764
}
9865

99-
asset, err := f.fsys.Open(uPath)
66+
asset, err := f.fsys.Open(path)
10067
if err != nil {
101-
tryIndex()
68+
serveIndex()
10269
return
10370
}
10471
defer asset.Close()
10572

106-
s, _ := asset.Stat()
73+
s, err := asset.Stat()
74+
if err != nil {
75+
serveIndex()
76+
return
77+
}
10778
if s.IsDir() {
108-
tryIndex()
79+
serveIndex()
10980
return
11081
}
11182

11283
w.Header().Set("Content-Type", mime.TypeByExtension(filepath.Ext(s.Name())))
84+
w.Header().Add("Vary", "Accept-Encoding")
11385

11486
buf := new(bytes.Buffer)
11587

11688
switch s.Name() {
11789
case "index.html", "oidc-callback.html", "oidc-silent-redirect.html":
11890
w.Header().Del("Expires")
11991
w.Header().Set("Cache-Control", "no-cache")
120-
_ = withBase(buf, asset, "/")
92+
err = withBase(buf, asset, "/")
93+
if err != nil {
94+
http.NotFound(w, r)
95+
return
96+
}
12197
default:
122-
_, _ = buf.ReadFrom(asset)
98+
_, err := buf.ReadFrom(asset)
99+
if err != nil {
100+
http.NotFound(w, r)
101+
return
102+
}
123103
}
124104

125-
_, _ = w.Write(buf.Bytes())
105+
_, err = w.Write(buf.Bytes())
106+
if err != nil {
107+
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
108+
return
109+
}
110+
}
111+
112+
func isValid(f *fileServer, path string) bool {
113+
if dir, ok := f.fsys.(http.Dir); ok {
114+
rootAbs, err := filepath.Abs(string(dir))
115+
if err != nil {
116+
return false
117+
}
118+
rel := path
119+
if len(rel) > 0 && rel[0] == '/' {
120+
rel = rel[1:]
121+
}
122+
candidate := filepath.Join(rootAbs, filepath.FromSlash(rel))
123+
fi, err := os.Lstat(candidate)
124+
if err != nil {
125+
return false
126+
}
127+
if fi.Mode()&os.ModeSymlink != 0 {
128+
return false
129+
}
130+
}
131+
return true
126132
}
127133

128134
func withBase(w io.Writer, r io.Reader, base string) error {

0 commit comments

Comments
 (0)