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

Merge commit from fork

* fix: FastCGI split SCRIPT_NAME/PATH_INFO confusion * fix comment

Kévin Dunglas committed Feb 10, 2026 at 19:52 UTC 7c28c0c07ac70a8960a166c7126150a408ba7464
2 files changed +332 -4
modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go
+86 -4
@@ -16,6 +16,7 @@ package fastcgi
16
17 import (
18 "crypto/tls"
19 + "errors"
20 "fmt"
21 "net"
22 "net/http"
@@ -23,9 +24,12 @@ import (
24 "strconv"
25 "strings"
26 "time"
27 + "unicode/utf8"
28
29 "go.uber.org/zap"
30 "go.uber.org/zap/zapcore"
31 + "golang.org/x/text/language"
32 + "golang.org/x/text/search"
33
34 "github.com/caddyserver/caddy/v2"
35 "github.com/caddyserver/caddy/v2/modules/caddyhttp"
@@ -33,7 +37,11 @@ import (
37 "github.com/caddyserver/caddy/v2/modules/caddytls"
38 )
39
36 -var noopLogger = zap.NewNop()
40 +var (
41 + ErrInvalidSplitPath = errors.New("split path contains non-ASCII characters")
42 +
43 + noopLogger = zap.NewNop()
44 +)
45
46 func init() {
47 caddy.RegisterModule(Transport{})
@@ -50,6 +58,9 @@ type Transport struct {
58 // actual resource (CGI script) name, and the second piece will be set to
59 // PATH_INFO for the CGI script to use.
60 //
61 + // Split paths can only contain ASCII characters.
62 + // Comparison is case-insensitive.
63 + //
64 // Future enhancements should be careful to avoid CVE-2019-11043,
65 // which can be mitigated with use of a try_files-like behavior
66 // that 404s if the fastcgi path info is not found.
@@ -109,6 +120,28 @@ func (t *Transport) Provision(ctx caddy.Context) error {
120 t.DialTimeout = caddy.Duration(3 * time.Second)
121 }
122
123 + var b strings.Builder
124 +
125 + for i, split := range t.SplitPath {
126 + b.Grow(len(split))
127 +
128 + for j := 0; j < len(split); j++ {
129 + c := split[j]
130 + if c >= utf8.RuneSelf {
131 + return ErrInvalidSplitPath
132 + }
133 +
134 + if 'A' <= c && c <= 'Z' {
135 + b.WriteByte(c + 'a' - 'A')
136 + } else {
137 + b.WriteByte(c)
138 + }
139 + }
140 +
141 + t.SplitPath[i] = b.String()
142 + b.Reset()
143 + }
144 +
145 return nil
146 }
147
@@ -385,8 +418,15 @@ func (t Transport) buildEnv(r *http.Request) (envVars, error) {
418 return env, nil
419 }
420
421 +var splitSearchNonASCII = search.New(language.Und, search.IgnoreCase)
422 +
423 // splitPos returns the index where path should
424 // be split based on t.SplitPath.
425 +//
426 +// example: if splitPath is [".php"]
427 +// "/path/to/script.php/some/path": ("/path/to/script.php", "/some/path")
428 +//
429 +// Adapted from FrankenPHP's code (copyright 2026 Kévin Dunglas, MIT license)
430 func (t Transport) splitPos(path string) int {
431 // TODO: from v1...
432 // if httpserver.CaseSensitivePath {
@@ -396,12 +436,54 @@ func (t Transport) splitPos(path string) int {
436 return 0
437 }
438
399 - lowerPath := strings.ToLower(path)
439 + pathLen := len(path)
440 +
441 + // We are sure that split strings are all ASCII-only and lower-case because of validation and normalization in Provision().
442 for _, split := range t.SplitPath {
401 - if idx := strings.Index(lowerPath, strings.ToLower(split)); idx > -1 {
402 - return idx + len(split)
443 + splitLen := len(split)
444 +
445 + for i := 0; i < pathLen; i++ {
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 +
458 + match := true
459 + for j := 0; j < splitLen; j++ {
460 + c := path[i+j]
461 +
462 + if c >= utf8.RuneSelf {
463 + if _, end := splitSearchNonASCII.IndexString(path, split); end > -1 {
464 + return end
465 + }
466 +
467 + break
468 + }
469 +
470 + if 'A' <= c && c <= 'Z' {
471 + c += 'a' - 'A'
472 + }
473 +
474 + if c != split[j] {
475 + match = false
476 +
477 + break
478 + }
479 + }
480 +
481 + if match {
482 + return i + splitLen
483 + }
484 }
485 }
486 +
487 return -1
488 }
489
modules/caddyhttp/reverseproxy/fastcgi/fastcgi_test.go new
+246
@@ -0,0 +1,246 @@
1 +package fastcgi
2 +
3 +import (
4 + "strings"
5 + "testing"
6 +
7 + "github.com/stretchr/testify/assert"
8 + "github.com/stretchr/testify/require"
9 +
10 + "github.com/caddyserver/caddy/v2"
11 +)
12 +
13 +func TestProvisionSplitPath(t *testing.T) {
14 + tests := []struct {
15 + name string
16 + splitPath []string
17 + wantErr error
18 + wantSplitPath []string
19 + }{
20 + {
21 + name: "valid lowercase split path",
22 + splitPath: []string{".php"},
23 + wantErr: nil,
24 + wantSplitPath: []string{".php"},
25 + },
26 + {
27 + name: "valid uppercase split path normalized",
28 + splitPath: []string{".PHP"},
29 + wantErr: nil,
30 + wantSplitPath: []string{".php"},
31 + },
32 + {
33 + name: "valid mixed case split path normalized",
34 + splitPath: []string{".PhP", ".PHTML"},
35 + wantErr: nil,
36 + wantSplitPath: []string{".php", ".phtml"},
37 + },
38 + {
39 + name: "empty split path",
40 + splitPath: []string{},
41 + wantErr: nil,
42 + wantSplitPath: []string{},
43 + },
44 + {
45 + name: "non-ASCII character in split path rejected",
46 + splitPath: []string{".php", ".Ⱥphp"},
47 + wantErr: ErrInvalidSplitPath,
48 + },
49 + {
50 + name: "unicode character in split path rejected",
51 + splitPath: []string{".phpⱥ"},
52 + wantErr: ErrInvalidSplitPath,
53 + },
54 + }
55 +
56 + for _, tt := range tests {
57 + t.Run(tt.name, func(t *testing.T) {
58 + tr := Transport{SplitPath: tt.splitPath}
59 + err := tr.Provision(caddy.Context{})
60 +
61 + if tt.wantErr != nil {
62 + require.ErrorIs(t, err, tt.wantErr)
63 +
64 + return
65 + }
66 +
67 + require.NoError(t, err)
68 + assert.Equal(t, tt.wantSplitPath, tr.SplitPath)
69 + })
70 + }
71 +}
72 +
73 +func TestSplitPos(t *testing.T) {
74 + tests := []struct {
75 + name string
76 + path string
77 + splitPath []string
78 + wantPos int
79 + }{
80 + {
81 + name: "simple php extension",
82 + path: "/path/to/script.php",
83 + splitPath: []string{".php"},
84 + wantPos: 19,
85 + },
86 + {
87 + name: "php extension with path info",
88 + path: "/path/to/script.php/some/path",
89 + splitPath: []string{".php"},
90 + wantPos: 19,
91 + },
92 + {
93 + name: "case insensitive match",
94 + path: "/path/to/script.PHP",
95 + splitPath: []string{".php"},
96 + wantPos: 19,
97 + },
98 + {
99 + name: "mixed case match",
100 + path: "/path/to/script.PhP/info",
101 + splitPath: []string{".php"},
102 + wantPos: 19,
103 + },
104 + {
105 + name: "no match",
106 + path: "/path/to/script.txt",
107 + splitPath: []string{".php"},
108 + wantPos: -1,
109 + },
110 + {
111 + name: "empty split path",
112 + path: "/path/to/script.php",
113 + splitPath: []string{},
114 + wantPos: 0,
115 + },
116 + {
117 + name: "multiple split paths first match",
118 + path: "/path/to/script.php",
119 + splitPath: []string{".php", ".phtml"},
120 + wantPos: 19,
121 + },
122 + {
123 + name: "multiple split paths second match",
124 + path: "/path/to/script.phtml",
125 + splitPath: []string{".php", ".phtml"},
126 + wantPos: 21,
127 + },
128 + // Unicode case-folding tests (security fix for GHSA-g966-83w7-6w38)
129 + // U+023A (Ⱥ) lowercases to U+2C65 (ⱥ), which has different UTF-8 byte length
130 + // Ⱥ: 2 bytes (C8 BA), ⱥ: 3 bytes (E2 B1 A5)
131 + {
132 + name: "unicode path with case-folding length expansion",
133 + path: "/ȺȺȺȺshell.php",
134 + splitPath: []string{".php"},
135 + wantPos: 18, // correct position in original string
136 + },
137 + {
138 + name: "unicode path with extension after expansion chars",
139 + path: "/ȺȺȺȺshell.php/path/info",
140 + splitPath: []string{".php"},
141 + wantPos: 18,
142 + },
143 + {
144 + name: "unicode in filename with multiple php occurrences",
145 + path: "/ȺȺȺȺshell.php.txt.php",
146 + splitPath: []string{".php"},
147 + wantPos: 18, // should match first .php, not be confused by byte offset shift
148 + },
149 + {
150 + name: "unicode case insensitive extension",
151 + path: "/ȺȺȺȺshell.PHP",
152 + splitPath: []string{".php"},
153 + wantPos: 18,
154 + },
155 + {
156 + name: "unicode in middle of path",
157 + path: "/path/Ⱥtest/script.php",
158 + splitPath: []string{".php"},
159 + wantPos: 23, // Ⱥ is 2 bytes, so path is 23 bytes total, .php ends at byte 23
160 + },
161 + {
162 + name: "unicode only in directory not filename",
163 + path: "/Ⱥ/script.php",
164 + splitPath: []string{".php"},
165 + wantPos: 14,
166 + },
167 + // Additional Unicode characters that expand when lowercased
168 + // U+0130 (İ - Turkish capital I with dot) lowercases to U+0069 + U+0307
169 + {
170 + name: "turkish capital I with dot",
171 + path: "/İtest.php",
172 + splitPath: []string{".php"},
173 + wantPos: 11,
174 + },
175 + // Ensure standard ASCII still works correctly
176 + {
177 + name: "ascii only path with case variation",
178 + path: "/PATH/TO/SCRIPT.PHP/INFO",
179 + splitPath: []string{".php"},
180 + wantPos: 19,
181 + },
182 + {
183 + name: "path at root",
184 + path: "/index.php",
185 + splitPath: []string{".php"},
186 + wantPos: 10,
187 + },
188 + {
189 + name: "extension in middle of filename",
190 + path: "/test.php.bak",
191 + splitPath: []string{".php"},
192 + wantPos: 9,
193 + },
194 + }
195 +
196 + for _, tt := range tests {
197 + t.Run(tt.name, func(t *testing.T) {
198 + gotPos := Transport{SplitPath: tt.splitPath}.splitPos(tt.path)
199 + assert.Equal(t, tt.wantPos, gotPos, "splitPos(%q, %v)", tt.path, tt.splitPath)
200 +
201 + // Verify that the split produces valid substrings
202 + if gotPos > 0 && gotPos <= len(tt.path) {
203 + scriptName := tt.path[:gotPos]
204 + pathInfo := tt.path[gotPos:]
205 +
206 + // The script name should end with one of the split extensions (case-insensitive)
207 + hasValidEnding := false
208 + for _, split := range tt.splitPath {
209 + if strings.HasSuffix(strings.ToLower(scriptName), split) {
210 + hasValidEnding = true
211 + break
212 + }
213 + }
214 + assert.True(t, hasValidEnding, "script name %q should end with one of %v", scriptName, tt.splitPath)
215 +
216 + // Original path should be reconstructable
217 + assert.Equal(t, tt.path, scriptName+pathInfo, "path should be reconstructable from split parts")
218 + }
219 + })
220 + }
221 +}
222 +
223 +// TestSplitPosUnicodeSecurityRegression specifically tests the vulnerability
224 +// described in GHSA-g966-83w7-6w38 where Unicode case-folding caused
225 +// incorrect SCRIPT_NAME/PATH_INFO splitting
226 +func TestSplitPosUnicodeSecurityRegression(t *testing.T) {
227 + // U+023A: Ⱥ (UTF-8: C8 BA). Lowercase is ⱥ (UTF-8: E2 B1 A5), longer in bytes.
228 + path := "/ȺȺȺȺshell.php.txt.php"
229 + split := []string{".php"}
230 +
231 + pos := Transport{SplitPath: split}.splitPos(path)
232 +
233 + // The vulnerable code would return 22 (computed on lowercased string)
234 + // The correct code should return 18 (position in original string)
235 + expectedPos := strings.Index(path, ".php") + len(".php")
236 + assert.Equal(t, expectedPos, pos, "split position should match first .php in original string")
237 + assert.Equal(t, 18, pos, "split position should be 18, not 22")
238 +
239 + if pos > 0 && pos <= len(path) {
240 + scriptName := path[:pos]
241 + pathInfo := path[pos:]
242 +
243 + assert.Equal(t, "/ȺȺȺȺshell.php", scriptName, "script name should be the path up to first .php")
244 + assert.Equal(t, ".txt.php", pathInfo, "path info should be the remainder after first .php")
245 + }
246 +}