Skip to content

Commit 04fdc0c

Browse files
committed
fix: path confusion via unicode casing in CGI path splitting
1 parent 711d032 commit 04fdc0c

6 files changed

Lines changed: 349 additions & 46 deletions

File tree

caddy/mercure.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,7 @@ func (f *FrankenPHPModule) assignMercureHub(ctx caddy.Context) {
2222
return
2323
}
2424

25-
opt := frankenphp.WithMercureHub(f.mercureHub)
26-
f.mercureHubRequestOption = &opt
25+
f.requestOptions = append(f.requestOptions, frankenphp.WithMercureHub(f.mercureHub))
2726

2827
for i, wc := range f.Workers {
2928
wc.mercureHub = f.mercureHub

caddy/module.go

Lines changed: 21 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ type FrankenPHPModule struct {
5151
preparedEnv frankenphp.PreparedEnv
5252
preparedEnvNeedsReplacement bool
5353
logger *slog.Logger
54-
mercureHubRequestOption *frankenphp.RequestOption
54+
requestOptions []frankenphp.RequestOption
5555
}
5656

5757
// CaddyModule returns the Caddy module information.
@@ -118,6 +118,7 @@ func (f *FrankenPHPModule) Provision(ctx caddy.Context) error {
118118
if len(f.SplitPath) == 0 {
119119
f.SplitPath = []string{".php"}
120120
}
121+
f.requestOptions = append(f.requestOptions, frankenphp.WithRequestSplitPath(f.SplitPath))
121122

122123
if f.ResolveRootSymlink == nil {
123124
rrs := true
@@ -148,6 +149,8 @@ func (f *FrankenPHPModule) Provision(ctx caddy.Context) error {
148149
f.Workers[i].FileName = resolvedPath
149150
}
150151
}
152+
153+
f.requestOptions = append(f.requestOptions, frankenphp.WithRequestResolvedDocumentRoot(f.resolvedDocumentRoot))
151154
}
152155

153156
if f.preparedEnv == nil {
@@ -162,6 +165,10 @@ func (f *FrankenPHPModule) Provision(ctx caddy.Context) error {
162165
}
163166
}
164167

168+
if !f.preparedEnvNeedsReplacement {
169+
f.requestOptions = append(f.requestOptions, frankenphp.WithRequestPreparedEnv(f.preparedEnv))
170+
}
171+
165172
if err := f.configureHotReload(fapp); err != nil {
166173
return err
167174
}
@@ -180,31 +187,26 @@ func (f *FrankenPHPModule) ServeHTTP(w http.ResponseWriter, r *http.Request, _ c
180187
origReq := ctx.Value(caddyhttp.OriginalRequestCtxKey).(http.Request)
181188
repl := ctx.Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
182189

183-
var (
184-
documentRootOption frankenphp.RequestOption
185-
documentRoot string
186-
)
187-
188-
if f.resolvedDocumentRoot == "" {
190+
documentRoot := f.resolvedDocumentRoot
191+
if documentRoot == "" {
189192
documentRoot = repl.ReplaceKnown(f.Root, "")
190193
if documentRoot == "" && frankenphp.EmbeddedAppPath != "" {
191194
documentRoot = frankenphp.EmbeddedAppPath
192195
}
196+
193197
// If we do not have a resolved document root, then we cannot resolve the symlink of our cwd because it may
194198
// resolve to a different directory than the one we are currently in.
195199
// This is especially important if there are workers running.
196-
documentRootOption = frankenphp.WithRequestDocumentRoot(documentRoot, false)
197-
} else {
198-
documentRoot = f.resolvedDocumentRoot
199-
documentRootOption = frankenphp.WithRequestResolvedDocumentRoot(documentRoot)
200+
f.requestOptions = append(f.requestOptions, frankenphp.WithRequestDocumentRoot(documentRoot, false))
200201
}
201202

202-
env := f.preparedEnv
203203
if f.preparedEnvNeedsReplacement {
204-
env = make(frankenphp.PreparedEnv, len(f.Env))
204+
env := make(frankenphp.PreparedEnv, len(f.Env))
205205
for k, v := range f.preparedEnv {
206206
env[k] = repl.ReplaceKnown(v, "")
207207
}
208+
209+
f.requestOptions = append(f.requestOptions, frankenphp.WithRequestPreparedEnv(env))
208210
}
209211

210212
workerName := ""
@@ -215,31 +217,14 @@ func (f *FrankenPHPModule) ServeHTTP(w http.ResponseWriter, r *http.Request, _ c
215217
}
216218
}
217219

218-
var (
219-
err error
220-
fr *http.Request
221-
)
222-
223-
if f.mercureHubRequestOption == nil {
224-
fr, err = frankenphp.NewRequestWithContext(
225-
r,
226-
documentRootOption,
227-
frankenphp.WithRequestSplitPath(f.SplitPath),
228-
frankenphp.WithRequestPreparedEnv(env),
229-
frankenphp.WithOriginalRequest(&origReq),
230-
frankenphp.WithWorkerName(workerName),
231-
)
232-
} else {
233-
fr, err = frankenphp.NewRequestWithContext(
234-
r,
235-
documentRootOption,
236-
frankenphp.WithRequestSplitPath(f.SplitPath),
237-
frankenphp.WithRequestPreparedEnv(env),
220+
fr, err := frankenphp.NewRequestWithContext(
221+
r,
222+
append(
223+
f.requestOptions,
238224
frankenphp.WithOriginalRequest(&origReq),
239225
frankenphp.WithWorkerName(workerName),
240-
*f.mercureHubRequestOption,
241-
)
242-
}
226+
)...,
227+
)
243228

244229
if err != nil {
245230
return caddyhttp.Error(http.StatusInternalServerError, err)

cgi.go

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,12 @@ import (
1818
"net/http"
1919
"path/filepath"
2020
"strings"
21+
"unicode/utf8"
2122
"unsafe"
2223

2324
"github.com/dunglas/frankenphp/internal/phpheaders"
25+
"golang.org/x/text/language"
26+
"golang.org/x/text/search"
2427
)
2528

2629
// Protocol versions, in Apache mod_ssl format: https://httpd.apache.org/docs/current/mod/mod_ssl.html
@@ -252,24 +255,64 @@ func splitCgiPath(fc *frankenPHPContext) {
252255
fc.worker = getWorkerByPath(fc.scriptFilename)
253256
}
254257

255-
// splitPos returns the index where path should
256-
// be split based on SplitPath.
258+
var splitSearchNonASCII = search.New(language.Und, search.IgnoreCase)
259+
260+
// splitPos returns the index where path should be split based on splitPath.
257261
// example: if splitPath is [".php"]
258262
// "/path/to/script.php/some/path": ("/path/to/script.php", "/some/path")
259-
//
260-
// Adapted from https://github.com/caddyserver/caddy/blob/master/modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go
261-
// Copyright 2015 Matthew Holt and The Caddy Authors
262263
func splitPos(path string, splitPath []string) int {
263264
if len(splitPath) == 0 {
264265
return 0
265266
}
266267

267-
lowerPath := strings.ToLower(path)
268+
pathLen := len(path)
269+
270+
// We are sure that split strings are all ASCII-only and lower-case because of validation and normalization in WithRequestSplitPath
268271
for _, split := range splitPath {
269-
if idx := strings.Index(lowerPath, strings.ToLower(split)); idx > -1 {
270-
return idx + len(split)
272+
splitLen := len(split)
273+
274+
for i := 0; i < pathLen; i++ {
275+
if path[i] >= utf8.RuneSelf {
276+
if _, end := splitSearchNonASCII.IndexString(path, split); end > -1 {
277+
return end
278+
}
279+
280+
break
281+
}
282+
283+
if i+splitLen > pathLen {
284+
continue
285+
}
286+
287+
match := true
288+
for j := 0; j < splitLen; j++ {
289+
c := path[i+j]
290+
291+
if c >= utf8.RuneSelf {
292+
if _, end := splitSearchNonASCII.IndexString(path, split); end > -1 {
293+
return end
294+
}
295+
296+
break
297+
}
298+
299+
if 'A' <= c && c <= 'Z' {
300+
c += 'a' - 'A'
301+
}
302+
303+
if c != split[j] {
304+
match = false
305+
306+
break
307+
}
308+
}
309+
310+
if match {
311+
return i + splitLen
312+
}
271313
}
272314
}
315+
273316
return -1
274317
}
275318

cgi_test.go

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package frankenphp
22

33
import (
4+
"strings"
45
"testing"
56

67
"github.com/stretchr/testify/assert"
@@ -31,3 +32,179 @@ func TestEnsureLeadingSlash(t *testing.T) {
3132
})
3233
}
3334
}
35+
36+
func TestSplitPos(t *testing.T) {
37+
tests := []struct {
38+
name string
39+
path string
40+
splitPath []string
41+
wantPos int
42+
}{
43+
{
44+
name: "simple php extension",
45+
path: "/path/to/script.php",
46+
splitPath: []string{".php"},
47+
wantPos: 19,
48+
},
49+
{
50+
name: "php extension with path info",
51+
path: "/path/to/script.php/some/path",
52+
splitPath: []string{".php"},
53+
wantPos: 19,
54+
},
55+
{
56+
name: "case insensitive match",
57+
path: "/path/to/script.PHP",
58+
splitPath: []string{".php"},
59+
wantPos: 19,
60+
},
61+
{
62+
name: "mixed case match",
63+
path: "/path/to/script.PhP/info",
64+
splitPath: []string{".php"},
65+
wantPos: 19,
66+
},
67+
{
68+
name: "no match",
69+
path: "/path/to/script.txt",
70+
splitPath: []string{".php"},
71+
wantPos: -1,
72+
},
73+
{
74+
name: "empty split path",
75+
path: "/path/to/script.php",
76+
splitPath: []string{},
77+
wantPos: 0,
78+
},
79+
{
80+
name: "multiple split paths first match",
81+
path: "/path/to/script.php",
82+
splitPath: []string{".php", ".phtml"},
83+
wantPos: 19,
84+
},
85+
{
86+
name: "multiple split paths second match",
87+
path: "/path/to/script.phtml",
88+
splitPath: []string{".php", ".phtml"},
89+
wantPos: 21,
90+
},
91+
// Unicode case-folding tests (security fix for GHSA-g966-83w7-6w38)
92+
// U+023A (Ⱥ) lowercases to U+2C65 (ⱥ), which has different UTF-8 byte length
93+
// Ⱥ: 2 bytes (C8 BA), ⱥ: 3 bytes (E2 B1 A5)
94+
{
95+
name: "unicode path with case-folding length expansion",
96+
path: "/ȺȺȺȺshell.php",
97+
splitPath: []string{".php"},
98+
wantPos: 18, // correct position in original string
99+
},
100+
{
101+
name: "unicode path with extension after expansion chars",
102+
path: "/ȺȺȺȺshell.php/path/info",
103+
splitPath: []string{".php"},
104+
wantPos: 18,
105+
},
106+
{
107+
name: "unicode in filename with multiple php occurrences",
108+
path: "/ȺȺȺȺshell.php.txt.php",
109+
splitPath: []string{".php"},
110+
wantPos: 18, // should match first .php, not be confused by byte offset shift
111+
},
112+
{
113+
name: "unicode case insensitive extension",
114+
path: "/ȺȺȺȺshell.PHP",
115+
splitPath: []string{".php"},
116+
wantPos: 18,
117+
},
118+
{
119+
name: "unicode in middle of path",
120+
path: "/path/Ⱥtest/script.php",
121+
splitPath: []string{".php"},
122+
wantPos: 23, // Ⱥ is 2 bytes, so path is 23 bytes total, .php ends at byte 23
123+
},
124+
{
125+
name: "unicode only in directory not filename",
126+
path: "/Ⱥ/script.php",
127+
splitPath: []string{".php"},
128+
wantPos: 14,
129+
},
130+
// Additional Unicode characters that expand when lowercased
131+
// U+0130 (İ - Turkish capital I with dot) lowercases to U+0069 + U+0307
132+
{
133+
name: "turkish capital I with dot",
134+
path: "/İtest.php",
135+
splitPath: []string{".php"},
136+
wantPos: 11,
137+
},
138+
// Ensure standard ASCII still works correctly
139+
{
140+
name: "ascii only path with case variation",
141+
path: "/PATH/TO/SCRIPT.PHP/INFO",
142+
splitPath: []string{".php"},
143+
wantPos: 19,
144+
},
145+
{
146+
name: "path at root",
147+
path: "/index.php",
148+
splitPath: []string{".php"},
149+
wantPos: 10,
150+
},
151+
{
152+
name: "extension in middle of filename",
153+
path: "/test.php.bak",
154+
splitPath: []string{".php"},
155+
wantPos: 9,
156+
},
157+
}
158+
159+
for _, tt := range tests {
160+
t.Run(tt.name, func(t *testing.T) {
161+
gotPos := splitPos(tt.path, tt.splitPath)
162+
assert.Equal(t, tt.wantPos, gotPos, "splitPos(%q, %v)", tt.path, tt.splitPath)
163+
164+
// Verify that the split produces valid substrings
165+
if gotPos > 0 && gotPos <= len(tt.path) {
166+
scriptName := tt.path[:gotPos]
167+
pathInfo := tt.path[gotPos:]
168+
169+
// The script name should end with one of the split extensions (case-insensitive)
170+
hasValidEnding := false
171+
for _, split := range tt.splitPath {
172+
if strings.HasSuffix(strings.ToLower(scriptName), split) {
173+
hasValidEnding = true
174+
175+
break
176+
}
177+
}
178+
assert.True(t, hasValidEnding, "script name %q should end with one of %v", scriptName, tt.splitPath)
179+
180+
// Original path should be reconstructable
181+
assert.Equal(t, tt.path, scriptName+pathInfo, "path should be reconstructable from split parts")
182+
}
183+
})
184+
}
185+
}
186+
187+
// TestSplitPosUnicodeSecurityRegression specifically tests the vulnerability
188+
// described in GHSA-g966-83w7-6w38 where Unicode case-folding caused
189+
// incorrect SCRIPT_NAME/PATH_INFO splitting
190+
func TestSplitPosUnicodeSecurityRegression(t *testing.T) {
191+
// U+023A: Ⱥ (UTF-8: C8 BA). Lowercase is ⱥ (UTF-8: E2 B1 A5), longer in bytes.
192+
path := "/ȺȺȺȺshell.php.txt.php"
193+
split := []string{".php"}
194+
195+
pos := splitPos(path, split)
196+
197+
// The vulnerable code would return 22 (computed on lowercased string)
198+
// The correct code should return 18 (position in original string)
199+
expectedPos := strings.Index(path, ".php") + len(".php")
200+
assert.Equal(t, expectedPos, pos, "split position should match first .php in original string")
201+
assert.Equal(t, 18, pos, "split position should be 18, not 22")
202+
203+
if pos > 0 && pos <= len(path) {
204+
scriptName := path[:pos]
205+
pathInfo := path[pos:]
206+
207+
assert.Equal(t, "/ȺȺȺȺshell.php", scriptName, "script name should be the path up to first .php")
208+
assert.Equal(t, ".txt.php", pathInfo, "path info should be the remainder after first .php")
209+
}
210+
}

0 commit comments

Comments
 (0)