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