package frankenphp import ( "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 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, }, } 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") } }