GitHub Security Advisory: GHSA-5r3v-vc8m-m96g
Package: github.com/caddyserver/caddy/v2 (GO)
Severity: HIGH (CVSS 0.0)
CWE: CWE-20: Improper Input Validation
Vulnerable versions: < 2.11.1
Patched in: 2.11.1

Summary: Caddy: Unicode case-folding length expansion causes incorrect split_path index in FastCGI transport

Details:
### Summary

Caddy's FastCGI path splitting logic computes the split index on a lowercased copy of the request path and then uses that byte index to slice the original path. This is unsafe for Unicode because `strings.ToLower()` can change UTF-8 byte length for some characters. As a result, Caddy can derive an incorrect `SCRIPT_NAME`/`SCRIPT_FILENAME` and `PATH_INFO`, potentially causing a request that contains `.php` to execute a different on-disk file than intended (path confusion). In setups where an attacker can control file contents (e.g., upload features), this can lead to unintended PHP execution of non-.php files (potential RCE depending on deployment).

### Details

The issue is in `github.com/caddyserver/caddy/modules/caddyhttp/fastcgi.Trasnport.splitPos()` (and the subsequent slicing in `buildEnv()`):

```
lowerPath := strings.ToLower(path)
idx := strings.Index(lowerPath, strings.ToLower(split))
return idx + len(split)
```

The returned index is computed in the byte space of lowerPath, but `buildEnv()` applies it to the original path:

- `docURI = path[:splitPos]`
- `pathInfo = path[splitPos:]`
- `scriptName = strings.TrimSuffix(path, fc.pathInfo)`
- `scriptFilename = caddyhttp.SanitizedPathJoin(fc.documentRoot, fc.scriptName)`

This assumes `lowerPath` and `path` have identical byte lengths and identical byte offsets, which is not true for some Unicode case mappings. Certain characters expand when lowercased (UTF-8 byte length increases), shifting the computed index. This creates a mismatch where `.php` is found in the lowercased string at an offset that does not correspond to the same position in the original string, causing the split point to land later/earlier than intended.

### PoC

Create a small Go program that reproduces Caddy's `splitPos()` behavior (compute the `.php` split point on a lowercased path, then use that byte index on the original path):

1. Save this as `poc.go`:

```go
package main

import (
	"fmt"
	"strings"
)

func splitPos(path string, split string) int {
	lowerPath := strings.ToLower(path)
	idx := strings.Index(lowerPath, strings.ToLower(split))
	if idx < 0 {
		return -1
	}
	return idx + len(split)
}

func main() {
	// U+023A: Ⱥ (UTF-8: C8 BA). Lowercase is ⱥ (UTF-8: E2 B1 A5), longer in bytes.
	path := "/ȺȺȺȺshell.php.txt.php"
	split := ".php"

	pos := splitPos(path, split)

	fmt.Printf("orig bytes=%d\n", len(path))
	fmt.Printf("lower bytes=%d\n", len(strings.ToLower(path)))
	fmt.Printf("splitPos=%d\n", pos)

	fmt.Printf("orig[:pos]=%q\n", path[:pos])
	fmt.Printf("orig[pos:]=%q\n", path[pos:])

	// Expected split: right after the first ".php" in the original string
	want := strings.Index(path, split) + len(split)
	fmt.Printf("expected splitPos=%d\n", want)
	fmt.Printf("expected orig[:]=%q\n", path[:want])
}
```

2. Run it:

```console
go run poc.go
```

Output on my side:

```
orig bytes=26
lower bytes=30
splitPos=22
orig[:pos]="/ȺȺȺȺshell.php.txt"
orig[pos:]=".php"
expected splitPos=18
expected orig[:]="/Ⱥ