Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS
Mirrored from https://github.com/Slazee/caddy.git
Synced Nov 11, 06:00 UTC
fileserver: reject short names in every path component (#7952)
* fileserver: reject short names in every path component Signed-off-by: DavidCarliez <271374756+DavidCarliez@users.noreply.github.com> * fileserver: validate short-name characters Signed-off-by: DavidCarliez <271374756+DavidCarliez@users.noreply.github.com> * fileserver: fail closed on extended short names Signed-off-by: DavidCarliez <271374756+DavidCarliez@users.noreply.github.com>
David Carliez committed
Aug 28, 2026 at 11:09 UTC
3244ef4105227e7882a95db0f6feb51dc59f10ae
3 files changed
+246
-2
modules/caddyhttp/fileserver/staticfiles.go
+53
-2
@@ -30,6 +30,7 @@ import (
30
"strconv"
31
"strings"
32
"time"
33
+ "unicode/utf8"
34
35
"go.uber.org/zap"
36
"go.uber.org/zap/zapcore"
@@ -266,6 +267,57 @@ func (fsrv *FileServer) Provision(ctx caddy.Context) error {
267
return nil
268
}
269
270
+const windowsShortNamePunctuation = "$%'-_@~`!(){}^#&"
271
+
272
+// possibleWindowsShortNamePart reports whether s could be part of an 8.3
273
+// alias. Extended characters are allowed when Windows' allowextchar behavior
274
+// is enabled, so non-ASCII characters must be accepted here to fail closed.
275
+func possibleWindowsShortNamePart(s string) bool {
276
+ for _, c := range s {
277
+ if c >= utf8.RuneSelf ||
278
+ (c >= 'a' && c <= 'z') ||
279
+ (c >= 'A' && c <= 'Z') ||
280
+ (c >= '0' && c <= '9') ||
281
+ strings.ContainsRune(windowsShortNamePunctuation, c) {
282
+ continue
283
+ }
284
+ return false
285
+ }
286
+ return true
287
+}
288
+
289
+// hasWindowsShortName reports whether any component of p has the syntax of an
290
+// NTFS 8.3 short name. Both slash types separate components on Windows, which
291
+// also ignores trailing dots and spaces in each component.
292
+func hasWindowsShortName(p string) bool {
293
+ for _, component := range strings.FieldsFunc(p, func(r rune) bool {
294
+ return r == '/' || r == '\\'
295
+ }) {
296
+ component = strings.TrimRight(component, ". ")
297
+ base, extension, hasExtension := strings.Cut(component, ".")
298
+ if utf8.RuneCountInString(base) > 8 || !possibleWindowsShortNamePart(base) ||
299
+ (hasExtension && (utf8.RuneCountInString(extension) > 3 || !possibleWindowsShortNamePart(extension))) {
300
+ continue
301
+ }
302
+
303
+ tilde := strings.LastIndexByte(base, '~')
304
+ if tilde <= 0 || tilde == len(base)-1 {
305
+ continue
306
+ }
307
+ shortNameNumber := base[tilde+1:]
308
+ for _, c := range shortNameNumber {
309
+ if c < '0' || c > '9' {
310
+ shortNameNumber = ""
311
+ break
312
+ }
313
+ }
314
+ if shortNameNumber != "" {
315
+ return true
316
+ }
317
+ }
318
+ return false
319
+}
320
+
321
func (fsrv *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
322
repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
323
@@ -275,8 +327,7 @@ func (fsrv *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request, next c
327
return caddyhttp.Error(http.StatusBadRequest, fmt.Errorf("illegal ADS path"))
328
}
329
// reject paths with "8.3" short names
278
- trimmedPath := strings.TrimRight(r.URL.Path, ". ") // Windows ignores trailing dots and spaces, sigh
279
- if len(path.Base(trimmedPath)) <= 12 && strings.Contains(trimmedPath, "~") {
330
+ if hasWindowsShortName(r.URL.Path) {
331
return caddyhttp.Error(http.StatusBadRequest, fmt.Errorf("illegal short name"))
332
}
333
// both of those could bypass file hiding or possibly leak information even if the file is not hidden
modules/caddyhttp/fileserver/staticfiles_test.go
+105
@@ -140,6 +140,111 @@ func TestFileHidden(t *testing.T) {
140
}
141
}
142
143
+func TestHasWindowsShortName(t *testing.T) {
144
+ for _, tc := range []struct {
145
+ name string
146
+ path string
147
+ want bool
148
+ }{
149
+ {
150
+ name: "final component",
151
+ path: "/DIRECT~1.TXT",
152
+ want: true,
153
+ },
154
+ {
155
+ name: "parent component with long final filename",
156
+ path: "/PROTEC~1/this-is-a-long-final-filename.txt",
157
+ want: true,
158
+ },
159
+ {
160
+ name: "middle component",
161
+ path: "/public/PROTEC~1/file.txt",
162
+ want: true,
163
+ },
164
+ {
165
+ name: "backslash separator",
166
+ path: `\public\PROTEC~1\file.txt`,
167
+ want: true,
168
+ },
169
+ {
170
+ name: "two digit short name suffix",
171
+ path: "/PROTE~12/file.txt",
172
+ want: true,
173
+ },
174
+ {
175
+ name: "trailing dots and spaces",
176
+ path: "/PROTEC~1. /file.txt",
177
+ want: true,
178
+ },
179
+ {
180
+ name: "permitted punctuation",
181
+ path: "/$%'-_~1.!#&",
182
+ want: true,
183
+ },
184
+ {
185
+ name: "ordinary tilde name",
186
+ path: "/ordinary~name/file.txt",
187
+ want: false,
188
+ },
189
+ {
190
+ name: "space in short name shape",
191
+ path: "/my f~1.txt",
192
+ want: false,
193
+ },
194
+ {
195
+ name: "space in long tilde name",
196
+ path: "/my file~1.txt",
197
+ want: false,
198
+ },
199
+ {
200
+ name: "non-ASCII short name shape in parent component",
201
+ path: "/public/café~1/this-is-a-long-final-filename.txt",
202
+ want: true,
203
+ },
204
+ {
205
+ name: "non-ASCII ordinary tilde name",
206
+ path: "/café~name.txt",
207
+ want: false,
208
+ },
209
+ {
210
+ name: "forbidden punctuation",
211
+ path: "/MY+F~1.TXT",
212
+ want: false,
213
+ },
214
+ {
215
+ name: "tilde followed by non-digit",
216
+ path: "/SHORT~A/file.txt",
217
+ want: false,
218
+ },
219
+ {
220
+ name: "characters after numeric suffix",
221
+ path: "/SHORT~1-file.txt",
222
+ want: false,
223
+ },
224
+ {
225
+ name: "base too long for 8.3",
226
+ path: "/TOOLONG~1/file.txt",
227
+ want: false,
228
+ },
229
+ {
230
+ name: "extension too long for 8.3",
231
+ path: "/SHORT~1.LONG/file.txt",
232
+ want: false,
233
+ },
234
+ {
235
+ name: "multiple extension separators",
236
+ path: "/SHORT~1.T.X/file.txt",
237
+ want: false,
238
+ },
239
+ } {
240
+ t.Run(tc.name, func(t *testing.T) {
241
+ if got := hasWindowsShortName(tc.path); got != tc.want {
242
+ t.Fatalf("hasWindowsShortName(%q) = %t, want %t", tc.path, got, tc.want)
243
+ }
244
+ })
245
+ }
246
+}
247
+
248
// Check to make sure that we don't serve ETag and Last-Modified headers
249
// for files with invalid modification times
250
func TestModTimeHeaders(t *testing.T) {
modules/caddyhttp/fileserver/staticfiles_windows_test.go
new
+88
@@ -0,0 +1,88 @@
1
+// Copyright 2015 Matthew Holt and The Caddy Authors
2
+//
3
+// Licensed under the Apache License, Version 2.0 (the "License");
4
+// you may not use this file except in compliance with the License.
5
+// You may obtain a copy of the License at
6
+//
7
+// http://www.apache.org/licenses/LICENSE-2.0
8
+//
9
+// Unless required by applicable law or agreed to in writing, software
10
+// distributed under the License is distributed on an "AS IS" BASIS,
11
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+// See the License for the specific language governing permissions and
13
+// limitations under the License.
14
+
15
+package fileserver
16
+
17
+import (
18
+ "context"
19
+ "errors"
20
+ "net/http"
21
+ "net/http/httptest"
22
+ "net/url"
23
+ "os"
24
+ "path/filepath"
25
+ "testing"
26
+
27
+ "github.com/caddyserver/caddy/v2"
28
+ "github.com/caddyserver/caddy/v2/modules/caddyhttp"
29
+)
30
+
31
+func TestFileServerRejectsShortNameInParentComponent(t *testing.T) {
32
+ root := t.TempDir()
33
+ ordinaryNames := []string{
34
+ "ordinary~name-with-long-suffix.txt",
35
+ "my f~1.txt",
36
+ "my file~1.txt",
37
+ "café~name.txt",
38
+ }
39
+ for _, name := range ordinaryNames {
40
+ if err := os.WriteFile(filepath.Join(root, name), []byte(name), 0o600); err != nil {
41
+ t.Fatal(err)
42
+ }
43
+ }
44
+
45
+ fsrv := FileServer{
46
+ Root: root,
47
+ CanonicalURIs: new(bool),
48
+ }
49
+ ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()})
50
+ if err := fsrv.Provision(ctx); err != nil {
51
+ t.Fatal(err)
52
+ }
53
+
54
+ for _, requestPath := range []string{
55
+ "/PROTEC~1/this-is-a-long-final-filename.txt",
56
+ "/public/caf%C3%A9~1/this-is-a-long-final-filename.txt",
57
+ } {
58
+ t.Run(requestPath, func(t *testing.T) {
59
+ err := fsrv.ServeHTTP(
60
+ httptest.NewRecorder(),
61
+ newPrecompressedRequest(t, requestPath),
62
+ nil,
63
+ )
64
+ var handlerErr caddyhttp.HandlerError
65
+ if !errors.As(err, &handlerErr) {
66
+ t.Fatalf("expected HandlerError, got %v", err)
67
+ }
68
+ if handlerErr.StatusCode != http.StatusBadRequest {
69
+ t.Fatalf("status = %d, want %d", handlerErr.StatusCode, http.StatusBadRequest)
70
+ }
71
+ })
72
+ }
73
+
74
+ for _, name := range ordinaryNames {
75
+ t.Run(name, func(t *testing.T) {
76
+ w := httptest.NewRecorder()
77
+ if err := fsrv.ServeHTTP(w, newPrecompressedRequest(t, "/"+url.PathEscape(name)), nil); err != nil {
78
+ t.Fatal(err)
79
+ }
80
+ if w.Code != http.StatusOK {
81
+ t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
82
+ }
83
+ if got := w.Body.String(); got != name {
84
+ t.Fatalf("body = %q, want %q", got, name)
85
+ }
86
+ })
87
+ }
88
+}