@marcel91200 / caddy Mirror · read-only / commits / 7145e5eb
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, 01:00 UTC

fastcgi: reject Win 8.3 aliases and alt data streams paths (#8356)

Kévin Dunglas committed Nov 4, 2026 at 20:34 UTC 7145e5eba89aec509c2115486f7f808f1ba93b65
6 files changed +203 -159
modules/caddyhttp/fileserver/staticfiles.go
+1 -53
@@ -31,7 +31,6 @@ import (
31 "strings"
32 "syscall"
33 "time"
34 - "unicode/utf8"
34
35 "go.uber.org/zap"
36 "go.uber.org/zap/zapcore"
@@ -275,57 +274,6 @@ func (fsrv *FileServer) Provision(ctx caddy.Context) error {
274 return nil
275 }
276
278 -const windowsShortNamePunctuation = "$%'-_@~`!(){}^#&"
279 -
280 -// possibleWindowsShortNamePart reports whether s could be part of an 8.3
281 -// alias. Extended characters are allowed when Windows' allowextchar behavior
282 -// is enabled, so non-ASCII characters must be accepted here to fail closed.
283 -func possibleWindowsShortNamePart(s string) bool {
284 - for _, c := range s {
285 - if c >= utf8.RuneSelf ||
286 - (c >= 'a' && c <= 'z') ||
287 - (c >= 'A' && c <= 'Z') ||
288 - (c >= '0' && c <= '9') ||
289 - strings.ContainsRune(windowsShortNamePunctuation, c) {
290 - continue
291 - }
292 - return false
293 - }
294 - return true
295 -}
296 -
297 -// hasWindowsShortName reports whether any component of p has the syntax of an
298 -// NTFS 8.3 short name. Both slash types separate components on Windows, which
299 -// also ignores trailing dots and spaces in each component.
300 -func hasWindowsShortName(p string) bool {
301 - for _, component := range strings.FieldsFunc(p, func(r rune) bool {
302 - return r == '/' || r == '\\'
303 - }) {
304 - component = strings.TrimRight(component, ". ")
305 - base, extension, hasExtension := strings.Cut(component, ".")
306 - if utf8.RuneCountInString(base) > 8 || !possibleWindowsShortNamePart(base) ||
307 - (hasExtension && (utf8.RuneCountInString(extension) > 3 || !possibleWindowsShortNamePart(extension))) {
308 - continue
309 - }
310 -
311 - tilde := strings.LastIndexByte(base, '~')
312 - if tilde <= 0 || tilde == len(base)-1 {
313 - continue
314 - }
315 - shortNameNumber := base[tilde+1:]
316 - for _, c := range shortNameNumber {
317 - if c < '0' || c > '9' {
318 - shortNameNumber = ""
319 - break
320 - }
321 - }
322 - if shortNameNumber != "" {
323 - return true
324 - }
325 - }
326 - return false
327 -}
328 -
277 func (fsrv *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
278 repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
279
@@ -335,7 +283,7 @@ func (fsrv *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request, next c
283 return caddyhttp.Error(http.StatusBadRequest, fmt.Errorf("illegal ADS path"))
284 }
285 // reject paths with "8.3" short names
338 - if hasWindowsShortName(r.URL.Path) {
286 + if caddyhttp.HasWindowsShortName(r.URL.Path) {
287 return caddyhttp.Error(http.StatusBadRequest, fmt.Errorf("illegal short name"))
288 }
289 // 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
@@ -142,111 +142,6 @@ func TestFileHidden(t *testing.T) {
142 }
143 }
144
145 -func TestHasWindowsShortName(t *testing.T) {
146 - for _, tc := range []struct {
147 - name string
148 - path string
149 - want bool
150 - }{
151 - {
152 - name: "final component",
153 - path: "/DIRECT~1.TXT",
154 - want: true,
155 - },
156 - {
157 - name: "parent component with long final filename",
158 - path: "/PROTEC~1/this-is-a-long-final-filename.txt",
159 - want: true,
160 - },
161 - {
162 - name: "middle component",
163 - path: "/public/PROTEC~1/file.txt",
164 - want: true,
165 - },
166 - {
167 - name: "backslash separator",
168 - path: `\public\PROTEC~1\file.txt`,
169 - want: true,
170 - },
171 - {
172 - name: "two digit short name suffix",
173 - path: "/PROTE~12/file.txt",
174 - want: true,
175 - },
176 - {
177 - name: "trailing dots and spaces",
178 - path: "/PROTEC~1. /file.txt",
179 - want: true,
180 - },
181 - {
182 - name: "permitted punctuation",
183 - path: "/$%'-_~1.!#&",
184 - want: true,
185 - },
186 - {
187 - name: "ordinary tilde name",
188 - path: "/ordinary~name/file.txt",
189 - want: false,
190 - },
191 - {
192 - name: "space in short name shape",
193 - path: "/my f~1.txt",
194 - want: false,
195 - },
196 - {
197 - name: "space in long tilde name",
198 - path: "/my file~1.txt",
199 - want: false,
200 - },
201 - {
202 - name: "non-ASCII short name shape in parent component",
203 - path: "/public/café~1/this-is-a-long-final-filename.txt",
204 - want: true,
205 - },
206 - {
207 - name: "non-ASCII ordinary tilde name",
208 - path: "/café~name.txt",
209 - want: false,
210 - },
211 - {
212 - name: "forbidden punctuation",
213 - path: "/MY+F~1.TXT",
214 - want: false,
215 - },
216 - {
217 - name: "tilde followed by non-digit",
218 - path: "/SHORT~A/file.txt",
219 - want: false,
220 - },
221 - {
222 - name: "characters after numeric suffix",
223 - path: "/SHORT~1-file.txt",
224 - want: false,
225 - },
226 - {
227 - name: "base too long for 8.3",
228 - path: "/TOOLONG~1/file.txt",
229 - want: false,
230 - },
231 - {
232 - name: "extension too long for 8.3",
233 - path: "/SHORT~1.LONG/file.txt",
234 - want: false,
235 - },
236 - {
237 - name: "multiple extension separators",
238 - path: "/SHORT~1.T.X/file.txt",
239 - want: false,
240 - },
241 - } {
242 - t.Run(tc.name, func(t *testing.T) {
243 - if got := hasWindowsShortName(tc.path); got != tc.want {
244 - t.Fatalf("hasWindowsShortName(%q) = %t, want %t", tc.path, got, tc.want)
245 - }
246 - })
247 - }
248 -}
249 -
145 // Check to make sure that we don't serve ETag and Last-Modified headers
146 // for files with invalid modification times
147 func TestModTimeHeaders(t *testing.T) {
modules/caddyhttp/matchers.go
+52
@@ -29,6 +29,7 @@ import (
29 "sort"
30 "strconv"
31 "strings"
32 + "unicode/utf8"
33
34 "cel.dev/cel-go/cel"
35 "cel.dev/cel-go/common"
@@ -703,6 +704,57 @@ func normalizeWindowsPath(p string) string {
704 return strings.Join(segments, "/")
705 }
706
707 +const windowsShortNamePunctuation = "$%'-_@~`!(){}^#&"
708 +
709 +// possibleWindowsShortNamePart reports whether s could be part of an 8.3
710 +// alias. Extended characters are allowed when Windows' allowextchar behavior
711 +// is enabled, so non-ASCII characters must be accepted here to fail closed.
712 +func possibleWindowsShortNamePart(s string) bool {
713 + for _, c := range s {
714 + if c >= utf8.RuneSelf ||
715 + (c >= 'a' && c <= 'z') ||
716 + (c >= 'A' && c <= 'Z') ||
717 + (c >= '0' && c <= '9') ||
718 + strings.ContainsRune(windowsShortNamePunctuation, c) {
719 + continue
720 + }
721 + return false
722 + }
723 + return true
724 +}
725 +
726 +// HasWindowsShortName reports whether any component of p has the syntax of an
727 +// NTFS 8.3 short name. Both slash types separate components on Windows, which
728 +// also ignores trailing dots and spaces in each component.
729 +func HasWindowsShortName(p string) bool {
730 + for _, component := range strings.FieldsFunc(p, func(r rune) bool {
731 + return r == '/' || r == '\\'
732 + }) {
733 + component = strings.TrimRight(component, ". ")
734 + base, extension, hasExtension := strings.Cut(component, ".")
735 + if utf8.RuneCountInString(base) > 8 || !possibleWindowsShortNamePart(base) ||
736 + (hasExtension && (utf8.RuneCountInString(extension) > 3 || !possibleWindowsShortNamePart(extension))) {
737 + continue
738 + }
739 +
740 + tilde := strings.LastIndexByte(base, '~')
741 + if tilde <= 0 || tilde == len(base)-1 {
742 + continue
743 + }
744 + shortNameNumber := base[tilde+1:]
745 + for _, c := range shortNameNumber {
746 + if c < '0' || c > '9' {
747 + shortNameNumber = ""
748 + break
749 + }
750 + }
751 + if shortNameNumber != "" {
752 + return true
753 + }
754 + }
755 + return false
756 +}
757 +
758 // normalizeWindowsEscapedPath applies the same Windows normalization as
759 // normalizeWindowsPath, but in raw/escaped space (used when a matcher pattern
760 // contains '%'), where path separators may be percent-encoded ("%2f") and a
modules/caddyhttp/matchers_test.go
+105
@@ -1491,3 +1491,108 @@ func BenchmarkHostMatcherWithPlaceholder(b *testing.B) {
1491 match.MatchWithError(req)
1492 }
1493 }
1494 +
1495 +func TestHasWindowsShortName(t *testing.T) {
1496 + for _, tc := range []struct {
1497 + name string
1498 + path string
1499 + want bool
1500 + }{
1501 + {
1502 + name: "final component",
1503 + path: "/DIRECT~1.TXT",
1504 + want: true,
1505 + },
1506 + {
1507 + name: "parent component with long final filename",
1508 + path: "/PROTEC~1/this-is-a-long-final-filename.txt",
1509 + want: true,
1510 + },
1511 + {
1512 + name: "middle component",
1513 + path: "/public/PROTEC~1/file.txt",
1514 + want: true,
1515 + },
1516 + {
1517 + name: "backslash separator",
1518 + path: `\public\PROTEC~1\file.txt`,
1519 + want: true,
1520 + },
1521 + {
1522 + name: "two digit short name suffix",
1523 + path: "/PROTE~12/file.txt",
1524 + want: true,
1525 + },
1526 + {
1527 + name: "trailing dots and spaces",
1528 + path: "/PROTEC~1. /file.txt",
1529 + want: true,
1530 + },
1531 + {
1532 + name: "permitted punctuation",
1533 + path: "/$%'-_~1.!#&",
1534 + want: true,
1535 + },
1536 + {
1537 + name: "ordinary tilde name",
1538 + path: "/ordinary~name/file.txt",
1539 + want: false,
1540 + },
1541 + {
1542 + name: "space in short name shape",
1543 + path: "/my f~1.txt",
1544 + want: false,
1545 + },
1546 + {
1547 + name: "space in long tilde name",
1548 + path: "/my file~1.txt",
1549 + want: false,
1550 + },
1551 + {
1552 + name: "non-ASCII short name shape in parent component",
1553 + path: "/public/café~1/this-is-a-long-final-filename.txt",
1554 + want: true,
1555 + },
1556 + {
1557 + name: "non-ASCII ordinary tilde name",
1558 + path: "/café~name.txt",
1559 + want: false,
1560 + },
1561 + {
1562 + name: "forbidden punctuation",
1563 + path: "/MY+F~1.TXT",
1564 + want: false,
1565 + },
1566 + {
1567 + name: "tilde followed by non-digit",
1568 + path: "/SHORT~A/file.txt",
1569 + want: false,
1570 + },
1571 + {
1572 + name: "characters after numeric suffix",
1573 + path: "/SHORT~1-file.txt",
1574 + want: false,
1575 + },
1576 + {
1577 + name: "base too long for 8.3",
1578 + path: "/TOOLONG~1/file.txt",
1579 + want: false,
1580 + },
1581 + {
1582 + name: "extension too long for 8.3",
1583 + path: "/SHORT~1.LONG/file.txt",
1584 + want: false,
1585 + },
1586 + {
1587 + name: "multiple extension separators",
1588 + path: "/SHORT~1.T.X/file.txt",
1589 + want: false,
1590 + },
1591 + } {
1592 + t.Run(tc.name, func(t *testing.T) {
1593 + if got := HasWindowsShortName(tc.path); got != tc.want {
1594 + t.Fatalf("HasWindowsShortName(%q) = %t, want %t", tc.path, got, tc.want)
1595 + }
1596 + })
1597 + }
1598 +}
modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go
+8 -1
@@ -21,6 +21,7 @@ import (
21 "net"
22 "net/http"
23 "path/filepath"
24 + "runtime"
25 "strconv"
26 "strings"
27 "time"
@@ -178,7 +179,7 @@ func (t Transport) RoundTrip(r *http.Request) (*http.Response, error) {
179
180 env, err := t.buildEnv(r)
181 if err != nil {
181 - return nil, fmt.Errorf("building environment: %v", err)
182 + return nil, fmt.Errorf("building environment: %w", err)
183 }
184
185 ctx := r.Context()
@@ -315,6 +316,12 @@ func (t Transport) buildEnv(r *http.Request) (envVars, error) {
316 scriptName = strings.TrimSuffix(scriptName, pathInfo)
317 }
318
319 + // On Windows, an 8.3 alias or an alternate data stream can name a non-PHP file
320 + // with a script-looking path, and path matchers cannot see through either.
321 + if runtime.GOOS == "windows" && (strings.Contains(scriptName, ":") || caddyhttp.HasWindowsShortName(scriptName)) {
322 + return nil, caddyhttp.Error(http.StatusBadRequest, errors.New("illegal script path"))
323 + }
324 +
325 // Try to grab the path remainder from a file matcher
326 // if we didn't get a split result here.
327 // See https://github.com/caddyserver/caddy/issues/3718
modules/caddyhttp/reverseproxy/fastcgi/fastcgi_windows_test.go
+37
@@ -72,3 +72,40 @@ func TestBuildEnvWindowsSeparatorsCannotSelectAnotherScript(t *testing.T) {
72 })
73 }
74 }
75 +
76 +// An 8.3 alias ("/UPLOAD~1.PHP" for "uploadedpayload.phpx") or an alternate
77 +// data stream ("a.txt:b.php") reaches a non-PHP file with a script-looking path.
78 +func TestBuildEnvWindowsScriptAliases(t *testing.T) {
79 + t.Parallel()
80 +
81 + for _, tc := range []struct {
82 + target string
83 + wantErr bool
84 + }{
85 + {"/UPLOAD~1.PHP", true},
86 + {"/PROTEC~1/index.php", true},
87 + {"/a.txt:b.php", true},
88 + {"/a.txt%3Ab.php", true},
89 + {"/index.php/urn:isbn:0451450523", false},
90 + {"/index.php/PROTEC~1", false},
91 + {"/index.php", false},
92 + } {
93 + t.Run(tc.target, func(t *testing.T) {
94 + r := httptest.NewRequest(http.MethodGet, tc.target, nil)
95 + ctx := context.WithValue(r.Context(), caddy.ReplacerCtxKey, caddy.NewReplacer())
96 + ctx = context.WithValue(ctx, caddyhttp.VarsCtxKey, map[string]any{})
97 + ctx = context.WithValue(ctx, caddyhttp.OriginalRequestCtxKey, *r)
98 +
99 + tr := Transport{Root: `C:\srv`, SplitPath: []string{".php"}}
100 + _, err := tr.buildEnv(r.WithContext(ctx))
101 + if !tc.wantErr {
102 + require.NoError(t, err)
103 + return
104 + }
105 +
106 + var herr caddyhttp.HandlerError
107 + require.ErrorAs(t, err, &herr)
108 + assert.Equal(t, http.StatusBadRequest, herr.StatusCode)
109 + })
110 + }
111 +}