-
Notifications
You must be signed in to change notification settings - Fork 488
Expand file tree
/
Copy pathcgi_test.go
More file actions
395 lines (364 loc) · 12.1 KB
/
Copy pathcgi_test.go
File metadata and controls
395 lines (364 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
package frankenphp
import (
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestEnsureLeadingSlash(t *testing.T) {
t.Parallel()
tests := []struct {
input string
expected string
}{
{"/index.php", "/index.php"},
{"index.php", "/index.php"},
{"/", "/"},
{"", ""},
{"/path/to/script.php", "/path/to/script.php"},
{"path/to/script.php", "/path/to/script.php"},
{"/index.php/path/info", "/index.php/path/info"},
{"index.php/path/info", "/index.php/path/info"},
}
for _, tt := range tests {
t.Run(tt.input+"-"+tt.expected, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.expected, ensureLeadingSlash(tt.input), "ensureLeadingSlash(%q)", tt.input)
})
}
}
func TestSplitRemoteAddr(t *testing.T) {
t.Parallel()
tests := []struct {
name string
addr string
wantIP string
wantPort string
}{
{"ipv4 with port", "1.2.3.4:5", "1.2.3.4", "5"},
{"ipv6 bracketed with port", "[::1]:443", "::1", "443"},
{"ipv6 zone bracketed with port", "[fe80::1%eth0]:443", "fe80::1%eth0", "443"},
{"ipv4 without port", "192.168.0.1", "192.168.0.1", ""},
{"empty", "", "", ""},
{"only colon", ":", "", ""},
// Must not panic: would crash the process via the cgo callback.
{"lone open bracket", "[", "[", ""},
{"open bracket with port", "[:9000", "[", "9000"},
{"empty brackets", "[]", "", ""},
{"opening bracket with colon", "[:", "[", ""},
{"unterminated bracket with port", "[::1:80", "[::1", "80"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ip, port := splitRemoteAddr(tt.addr)
assert.Equal(t, tt.wantIP, ip, "splitRemoteAddr(%q) ip", tt.addr)
assert.Equal(t, tt.wantPort, port, "splitRemoteAddr(%q) port", tt.addr)
})
}
}
func TestSplitPos(t *testing.T) {
tests := []struct {
name string
path string
splitPath []string
wantPos int
}{
{
name: "simple php extension",
path: "/path/to/script.php",
splitPath: []string{".php"},
wantPos: 19,
},
{
name: "php extension with path info",
path: "/path/to/script.php/some/path",
splitPath: []string{".php"},
wantPos: 19,
},
{
name: "case insensitive match",
path: "/path/to/script.PHP",
splitPath: []string{".php"},
wantPos: 19,
},
{
name: "mixed case match",
path: "/path/to/script.PhP/info",
splitPath: []string{".php"},
wantPos: 19,
},
{
name: "no match",
path: "/path/to/script.txt",
splitPath: []string{".php"},
wantPos: -1,
},
{
name: "empty split path",
path: "/path/to/script.php",
splitPath: []string{},
wantPos: 0,
},
{
name: "multiple split paths first match",
path: "/path/to/script.php",
splitPath: []string{".php", ".phtml"},
wantPos: 19,
},
{
name: "multiple split paths second match",
path: "/path/to/script.phtml",
splitPath: []string{".php", ".phtml"},
wantPos: 21,
},
// Unicode case-folding tests (security fix for GHSA-g966-83w7-6w38)
// U+023A (Ⱥ) lowercases to U+2C65 (ⱥ), which has different UTF-8 byte length
// Ⱥ: 2 bytes (C8 BA), ⱥ: 3 bytes (E2 B1 A5)
{
name: "unicode path with case-folding length expansion",
path: "/ȺȺȺȺshell.php",
splitPath: []string{".php"},
wantPos: 18, // correct position in original string
},
{
name: "unicode path with extension after expansion chars",
path: "/ȺȺȺȺshell.php/path/info",
splitPath: []string{".php"},
wantPos: 18,
},
{
name: "unicode in filename with multiple php occurrences",
path: "/ȺȺȺȺshell.php.txt.php",
splitPath: []string{".php"},
wantPos: 18, // should match first .php, not be confused by byte offset shift
},
{
name: "unicode case insensitive extension",
path: "/ȺȺȺȺshell.PHP",
splitPath: []string{".php"},
wantPos: 18,
},
{
name: "unicode in middle of path",
path: "/path/Ⱥtest/script.php",
splitPath: []string{".php"},
wantPos: 23, // Ⱥ is 2 bytes, so path is 23 bytes total, .php ends at byte 23
},
{
name: "unicode only in directory not filename",
path: "/Ⱥ/script.php",
splitPath: []string{".php"},
wantPos: 14,
},
// Additional Unicode characters that expand when lowercased
// U+0130 (İ - Turkish capital I with dot) lowercases to U+0069 + U+0307
{
name: "turkish capital I with dot",
path: "/İtest.php",
splitPath: []string{".php"},
wantPos: 11,
},
// Ensure standard ASCII still works correctly
{
name: "ascii only path with case variation",
path: "/PATH/TO/SCRIPT.PHP/INFO",
splitPath: []string{".php"},
wantPos: 19,
},
{
name: "path at root",
path: "/index.php",
splitPath: []string{".php"},
wantPos: 10,
},
{
name: "extension in middle of filename",
path: "/test.php.bak",
splitPath: []string{".php"},
wantPos: 9,
},
// Regression tests for GHSA-3g8v-8r37-cgjm: an inner non-ASCII byte
// caused the loop to break without resetting match=false, so a path
// such as "/PoC-match-unset.¡.txt" was reported as ".php" matched.
{
name: "non-ascii byte after dot must not match",
path: "/PoC-match-unset.¡.txt",
splitPath: []string{".php"},
wantPos: -1,
},
{
name: "non-ascii byte mid-extension must not match",
path: "/script.p\xc2\xa1p",
splitPath: []string{".php"},
wantPos: -1,
},
// Regression tests for GHSA-v4h7-cj44-8fc8: search.IgnoreCase folded
// Unicode equivalents (fullwidth, mathematical, circled letters,
// fullwidth/small full-stop) onto ASCII ".php".
{
name: "small full stop ﹒ in extension must not match",
path: "/shell﹒php",
splitPath: []string{".php"},
wantPos: -1,
},
{
name: "fullwidth full stop . in extension must not match",
path: "/shell.php",
splitPath: []string{".php"},
wantPos: -1,
},
{
name: "fullwidth p in extension must not match",
path: "/shell.php",
splitPath: []string{".php"},
wantPos: -1,
},
{
name: "circled php must not match",
path: "/shell.ⓟⓗⓟ",
splitPath: []string{".php"},
wantPos: -1,
},
{
name: "mathematical sans-serif bold php must not match",
path: "/shell.\U0001D5FD\U0001D5F5\U0001D5FD",
splitPath: []string{".php"},
wantPos: -1,
},
{
name: "mathematical script php must not match",
path: "/shell.\U0001D4C5\U0001D4BD\U0001D4C5",
splitPath: []string{".php"},
wantPos: -1,
},
{
name: "circled php with later real php still picks the real one",
path: "/shell.ⓟⓗⓟ.anything-after-payload.php",
splitPath: []string{".php"},
// "/shell." (7) + "ⓟⓗⓟ" (3*3 bytes) + ".anything-after-payload.php" (27) = 43
wantPos: 43,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotPos := splitPos(tt.path, tt.splitPath)
assert.Equal(t, tt.wantPos, gotPos, "splitPos(%q, %v)", tt.path, tt.splitPath)
// Verify that the split produces valid substrings
if gotPos > 0 && gotPos <= len(tt.path) {
scriptName := tt.path[:gotPos]
pathInfo := tt.path[gotPos:]
// The script name should end with one of the split extensions (case-insensitive)
hasValidEnding := false
for _, split := range tt.splitPath {
if strings.HasSuffix(strings.ToLower(scriptName), split) {
hasValidEnding = true
break
}
}
assert.True(t, hasValidEnding, "script name %q should end with one of %v", scriptName, tt.splitPath)
// Original path should be reconstructable
assert.Equal(t, tt.path, scriptName+pathInfo, "path should be reconstructable from split parts")
}
})
}
}
// TestSplitPosUnicodeSecurityRegression specifically tests the vulnerability
// described in GHSA-g966-83w7-6w38 where Unicode case-folding caused
// incorrect SCRIPT_NAME/PATH_INFO splitting
func TestSplitPosUnicodeSecurityRegression(t *testing.T) {
// U+023A: Ⱥ (UTF-8: C8 BA). Lowercase is ⱥ (UTF-8: E2 B1 A5), longer in bytes.
path := "/ȺȺȺȺshell.php.txt.php"
split := []string{".php"}
pos := splitPos(path, split)
// The vulnerable code would return 22 (computed on lowercased string)
// The correct code should return 18 (position in original string)
expectedPos := strings.Index(path, ".php") + len(".php")
assert.Equal(t, expectedPos, pos, "split position should match first .php in original string")
assert.Equal(t, 18, pos, "split position should be 18, not 22")
if pos > 0 && pos <= len(path) {
scriptName := path[:pos]
pathInfo := path[pos:]
assert.Equal(t, "/ȺȺȺȺshell.php", scriptName, "script name should be the path up to first .php")
assert.Equal(t, ".txt.php", pathInfo, "path info should be the remainder after first .php")
}
}
// TestSplitPosSecurityRegressionUnicodeBypass guards against
// GHSA-3g8v-8r37-cgjm (uninitialized match flag on inner non-ASCII byte) and
// GHSA-v4h7-cj44-8fc8 (Unicode equivalence via search.IgnoreCase letting
// non-PHP files be picked up as the script). Every payload below produced a
// false positive in the vulnerable implementation; none must match here.
func TestSplitPosSecurityRegressionUnicodeBypass(t *testing.T) {
t.Parallel()
split := []string{".php"}
payloads := []string{
"/PoC-match-unset.¡.txt", // GHSA-3g8v: match left set after IndexString fallback returned -1
"/shell﹒php", // U+FE52 small full stop
"/shell.php", // U+FF0E fullwidth full stop
"/shell.php", // U+FF50 fullwidth p
"/shell.php", // U+FF48 fullwidth h
"/shell.php", // U+FF50 fullwidth p (trailing)
"/shell.\U0001D5C1\U0001D5B5\U0001D5C1", // mathematical sans-serif p/h
"/shell.\U0001D5FD\U0001D5F5\U0001D5FD", // mathematical sans-serif bold p/h
"/shell.\U0001D4C5\U0001D4BD\U0001D4C5", // mathematical script p/h
"/shell.ⓟⓗⓟ", // circled latin small
}
for _, p := range payloads {
assert.Equalf(t, -1, splitPos(p, split), "payload %q must not be detected as .php", p)
}
}
// FuzzSplitPos guards the byte/rune-boundary arithmetic behind the Unicode
// case-folding bypasses above (GHSA-3g8v-8r37-cgjm, GHSA-v4h7-cj44-8fc8):
// splitPos must never return an out-of-bounds position, whatever bytes are
// thrown at it.
func FuzzSplitPos(f *testing.F) {
f.Add("/path/to/script.php", ".php")
f.Add("/path/to/script.php/some/path", ".php")
f.Add("/ȺȺȺȺshell.php.txt.php", ".php")
f.Add("/shell﹒php", ".php")
f.Add("", "")
f.Fuzz(func(t *testing.T, path, splitMarker string) {
pos := splitPos(path, []string{splitMarker})
if pos < -1 || pos > len(path) {
t.Fatalf("splitPos(%q, %q) returned out-of-bounds position %d for a %d-byte path", path, splitMarker, pos, len(path))
}
})
}
// FuzzSanitizedPathJoin checks that the request path can never escape root,
// however it's mangled.
func FuzzSanitizedPathJoin(f *testing.F) {
f.Add("/var/www/html", "/index.php")
f.Add("/var/www/html", "../../etc/passwd")
f.Add("/var/www/html", "..\\..\\windows\\win.ini")
f.Add("", "/../../../etc/passwd")
f.Add("/var/www/html", "")
f.Fuzz(func(t *testing.T, root, reqPath string) {
result := sanitizedPathJoin(root, reqPath)
cleanRoot := root
if cleanRoot == "" {
cleanRoot = "."
}
rel, err := filepath.Rel(cleanRoot, result)
if err != nil {
// Different volumes on Windows and the like: not a traversal, just
// an unrelated path, but it must still not happen for a plain root.
return
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
t.Fatalf("sanitizedPathJoin(%q, %q) = %q escapes root", root, reqPath, result)
}
})
}
// FuzzSplitRemoteAddr guards against panics: it's called from a cgo
// callback, so a panic here would crash the process.
func FuzzSplitRemoteAddr(f *testing.F) {
f.Add("1.2.3.4:5")
f.Add("[::1]:443")
f.Add("[fe80::1%eth0]:443")
f.Add("[")
f.Add("[:9000")
f.Add("")
f.Fuzz(func(t *testing.T, remoteAddr string) {
splitRemoteAddr(remoteAddr)
})
}