@marcel91200 / caddy Mirror · read-only / commits / 3244ef41
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 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 +}