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