@marcel91200 / caddy Mirror · read-only / commits / fb324331
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, 12:15 UTC

Merge commit from fork

Both fallbacks in splitPos relied on golang.org/x/text/search with search.IgnoreCase, which performs Unicode equivalence matching far beyond ASCII case folding. Combined with the validated-ASCII guarantee on every SplitPath entry, that fallback turned non-PHP filenames into PHP scripts: - when the inner loop hit a non-ASCII byte and the IndexString fallback returned -1, the loop broke without resetting match=false, so a stale match=true caused a non-existent .php to be reported (PoC: "/name.<U+00A1>.txt"). - search.IgnoreCase folded fullwidth, mathematical and circled letters onto ASCII, so "/shell.<math sans-serif php>", "/shell.<fullwidth p>hp", "/shell.<circled php>" were all detected as ".php" files. Replace the fallback with strict byte-level ASCII case-insensitive matching: any byte >= utf8.RuneSelf in the path can never be part of a match, since SplitPath entries are validated ASCII-only and lower-cased in Provision(). This keeps the hot path branch-light and removes the x/text/search dependency from the main module. Reported against FrankenPHP as GHSA-3g8v-8r37-cgjm and GHSA-v4h7-cj44-8fc8. The vulnerable function in this module was adapted from the same FrankenPHP code.

Kévin Dunglas committed May 7, 2026 at 21:59 UTC fb324331f40782ac7a48d83f591c2bb7615d7eed
3 files changed +101 -24
go.mod
+1 -1
@@ -170,7 +170,7 @@ require (
170 go.uber.org/multierr v1.11.0 // indirect
171 golang.org/x/mod v0.35.0 // indirect
172 golang.org/x/sys v0.43.0
173 - golang.org/x/text v0.36.0
173 + golang.org/x/text v0.36.0 // indirect
174 golang.org/x/tools v0.44.0 // indirect
175 google.golang.org/grpc v1.80.0 // indirect
176 google.golang.org/protobuf v1.36.11 // indirect
modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go
+13 -23
@@ -28,8 +28,6 @@ import (
28
29 "go.uber.org/zap"
30 "go.uber.org/zap/zapcore"
31 - "golang.org/x/text/language"
32 - "golang.org/x/text/search"
31
32 "github.com/caddyserver/caddy/v2"
33 "github.com/caddyserver/caddy/v2/modules/caddyhttp"
@@ -418,14 +416,19 @@ func (t Transport) buildEnv(r *http.Request) (envVars, error) {
416 return env, nil
417 }
418
421 -var splitSearchNonASCII = search.New(language.Und, search.IgnoreCase)
422 -
419 // splitPos returns the index where path should
420 // be split based on t.SplitPath.
421 //
422 // example: if splitPath is [".php"]
423 // "/path/to/script.php/some/path": ("/path/to/script.php", "/some/path")
424 //
425 +// Matching is strictly ASCII case-insensitive. Bytes >= utf8.RuneSelf in path
426 +// never match any split entry: split strings are validated ASCII-only and
427 +// lower-cased in Provision(), so any Unicode equivalence (e.g. fullwidth or
428 +// mathematical letters folding to ASCII) would let an attacker upload a file
429 +// whose name contains such code points and have it served as PHP. See
430 +// FrankenPHP advisories GHSA-3g8v-8r37-cgjm and GHSA-v4h7-cj44-8fc8.
431 +//
432 // Adapted from FrankenPHP's code (copyright 2026 Kévin Dunglas, MIT license)
433 func (t Transport) splitPos(path string) int {
434 // TODO: from v1...
@@ -438,31 +441,18 @@ func (t Transport) splitPos(path string) int {
441
442 pathLen := len(path)
443
441 - // We are sure that split strings are all ASCII-only and lower-case because of validation and normalization in Provision().
444 for _, split := range t.SplitPath {
445 splitLen := len(split)
446 + if splitLen == 0 || splitLen > pathLen {
447 + continue
448 + }
449
445 - for i := range pathLen {
446 - if path[i] >= utf8.RuneSelf {
447 - if _, end := splitSearchNonASCII.IndexString(path, split); end > -1 {
448 - return end
449 - }
450 -
451 - break
452 - }
453 -
454 - if i+splitLen > pathLen {
455 - continue
456 - }
457 -
450 + for i := 0; i <= pathLen-splitLen; i++ {
451 match := true
459 - for j := range splitLen {
452 + for j := 0; j < splitLen; j++ {
453 c := path[i+j]
461 -
454 if c >= utf8.RuneSelf {
463 - if _, end := splitSearchNonASCII.IndexString(path, split); end > -1 {
464 - return end
465 - }
455 + match = false
456
457 break
458 }
modules/caddyhttp/reverseproxy/fastcgi/fastcgi_test.go
+87
@@ -191,6 +191,65 @@ func TestSplitPos(t *testing.T) {
191 splitPath: []string{".php"},
192 wantPos: 9,
193 },
194 + // Regression tests adapted from FrankenPHP advisories
195 + // GHSA-3g8v-8r37-cgjm and GHSA-v4h7-cj44-8fc8: search.IgnoreCase
196 + // matched Unicode equivalents of ASCII letters as ".php", and an
197 + // inner non-ASCII byte path could leave the match flag stale.
198 + {
199 + name: "non-ascii byte after dot must not match",
200 + path: "/PoC-match-unset.¡.txt",
201 + splitPath: []string{".php"},
202 + wantPos: -1,
203 + },
204 + {
205 + name: "non-ascii byte mid-extension must not match",
206 + path: "/script.p\xc2\xa1p",
207 + splitPath: []string{".php"},
208 + wantPos: -1,
209 + },
210 + {
211 + name: "small full stop ﹒ in extension must not match",
212 + path: "/shell﹒php",
213 + splitPath: []string{".php"},
214 + wantPos: -1,
215 + },
216 + {
217 + name: "fullwidth full stop . in extension must not match",
218 + path: "/shell.php",
219 + splitPath: []string{".php"},
220 + wantPos: -1,
221 + },
222 + {
223 + name: "fullwidth p in extension must not match",
224 + path: "/shell.php",
225 + splitPath: []string{".php"},
226 + wantPos: -1,
227 + },
228 + {
229 + name: "circled php must not match",
230 + path: "/shell.ⓟⓗⓟ",
231 + splitPath: []string{".php"},
232 + wantPos: -1,
233 + },
234 + {
235 + name: "mathematical sans-serif bold php must not match",
236 + path: "/shell.\U0001D5FD\U0001D5F5\U0001D5FD",
237 + splitPath: []string{".php"},
238 + wantPos: -1,
239 + },
240 + {
241 + name: "mathematical script php must not match",
242 + path: "/shell.\U0001D4C5\U0001D4BD\U0001D4C5",
243 + splitPath: []string{".php"},
244 + wantPos: -1,
245 + },
246 + {
247 + name: "circled php with later real php still picks the real one",
248 + path: "/shell.ⓟⓗⓟ.anything-after-payload.php",
249 + splitPath: []string{".php"},
250 + // "/shell." (7) + "ⓟⓗⓟ" (3*3 bytes) + ".anything-after-payload.php" (27) = 43
251 + wantPos: 43,
252 + },
253 }
254
255 for _, tt := range tests {
@@ -244,3 +303,31 @@ func TestSplitPosUnicodeSecurityRegression(t *testing.T) {
303 assert.Equal(t, ".txt.php", pathInfo, "path info should be the remainder after first .php")
304 }
305 }
306 +
307 +// TestSplitPosSecurityRegressionUnicodeBypass guards against the FrankenPHP
308 +// advisories GHSA-3g8v-8r37-cgjm (uninitialized match flag on inner non-ASCII
309 +// byte) and GHSA-v4h7-cj44-8fc8 (Unicode equivalence via search.IgnoreCase
310 +// folding fullwidth/mathematical/circled letters onto ASCII). Every payload
311 +// below produced a false positive in the vulnerable implementation; none
312 +// must match here.
313 +func TestSplitPosSecurityRegressionUnicodeBypass(t *testing.T) {
314 + t.Parallel()
315 +
316 + tr := Transport{SplitPath: []string{".php"}}
317 + payloads := []string{
318 + "/PoC-match-unset.¡.txt", // GHSA-3g8v: stale match=true on IndexString fallback
319 + "/shell﹒php", // U+FE52 small full stop
320 + "/shell.php", // U+FF0E fullwidth full stop
321 + "/shell.php", // U+FF50 fullwidth p
322 + "/shell.php", // U+FF48 fullwidth h
323 + "/shell.php", // U+FF50 fullwidth p (trailing)
324 + "/shell.\U0001D5C1\U0001D5B5\U0001D5C1", // mathematical sans-serif p/h
325 + "/shell.\U0001D5FD\U0001D5F5\U0001D5FD", // mathematical sans-serif bold p/h
326 + "/shell.\U0001D4C5\U0001D4BD\U0001D4C5", // mathematical script p/h
327 + "/shell.ⓟⓗⓟ", // circled latin small
328 + }
329 +
330 + for _, p := range payloads {
331 + assert.Equalf(t, -1, tr.splitPos(p), "payload %q must not be detected as .php", p)
332 + }
333 +}