CVE-2026-27590: Caddy Unicode Case-folding Length Expansion Causes Vulnerability

HIGH
Advisory Identifier: GHSA-5r3v-vc8m-m96g
CWE-20
Published
Advisory Summary

CVE-2026-27590 is a HIGH unicode case-folding length expansion causes vulnerability (CWE-20: Improper Input Validation) affecting Caddy libraries in the Go ecosystem. The issue involves unicode case-folding length expansion causes incorrect split_path index in fastcgi transport. Remediation is available by upgrading affected packages to version 2.11.1.

CVE-2026-27590 at a Glance

CVE Identifier CVE-2026-27590
GHSA Identifier GHSA-5r3v-vc8m-m96g
Severity HIGH
CVSS v3.1 Base Score N/A
CWE Classification CWE-20: Improper Input Validation
Affected Product Caddy
Package Ecosystem Go
Published Date
Last Updated

How to Fix CVE-2026-27590

To remediate this vulnerability, upgrade all affected Caddy dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

github.com/caddyserver/caddy/v2 fixed: 2.11.1
Upgrade github.com/caddyserver/caddy/v2 to 2.11.1
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for github.com/caddyserver/caddy/v2

How to Check if You Are Affected

To determine whether your application is affected by CVE-2026-27590, inspect your project dependencies for Caddy:

inspect dependencies for github.com/caddyserver/caddy/v2

Analyze the terminal output to locate any version of Caddy in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 2.11.1), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-20: Improper Input Validation, where the product receives input or data, but it does not validate or incorrectly validates that the input has the properties that are required to process the data safely and correctly. In Caddy, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

### 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[:]="/ȺȺȺȺshell.php" ``` Expected split is right after the first `.php` (`/ȺȺȺȺshell.php`). Instead, the computed split lands later and cuts the original path after `shell.php.txt`, leaving `.php` as the remainder. ### Impact Security boundary bypass/path confusion in script resolution. In typical deployments, `.php` extension boundaries are relied on to decide what is executed by PHP. This bug can cause Caddy/FPM to execute a different file than intended by confusing `SCRIPT_NAME`/`SCRIPT_FILENAME`. If an attacker can place attacker-controlled content into a file that can be resolved as `SCRIPT_FILENAME` (common in web apps with uploads or writable directories), this can lead to unintended PHP execution of non-.php files and potentially remote code execution. Severity depends on deployment and presence of attacker-controlled file writes, but the primitive itself is remotely triggerable via crafted URLs. This vulnerability was initially reported to FrankenPHP (https://github.com/php/frankenphp/security/advisories/GHSA-g966-83w7-6w38) by @AbdrrahimDahmani. The affected code has been copied/adapted from Caddy, which, according to research, is also affected. The patch is a port of the FrankenPHP patch.

CVE-2026-27590 Affected Caddy Versions

1 component(s)
Fixed Versions:
2.11.1
Product / Package Ecosystem Affected Versions Fixed Version
github.com/caddyserver/caddy/v2
Go
< 2.11.1
2.11.1

References

Frequently Asked Questions

What is CVE-2026-27590?
CVE-2026-27590 is a security vulnerability in Caddy involving Unicode case-folding length expansion causes incorrect split_path index in FastCGI transport under CWE-20: Improper Input Validation.
Which Caddy versions are affected?
Affected versions include releases prior to 2.11.1 across affected packages: github.com/caddyserver/caddy/v2.
What version fixes CVE-2026-27590?
Upgrading to 2.11.1 (or the latest patched release for your respective branch) resolves the vulnerability.
Is github.com/caddyserver/caddy/v2 affected?
Yes, github.com/caddyserver/caddy/v2 is affected in releases prior to 2.11.1. Upgrade your dependency to 2.11.1 or later.
What CWE is associated with CVE-2026-27590?
CVE-2026-27590 is classified under CWE-20: Improper Input Validation (The product receives input or data, but it does not validate or incorrectly validates that the input has the properties that are required to process the data safely and correctly.).
How can I check Go dependencies for CVE-2026-27590?
Run the dependency tree command: 'inspect dependencies for github.com/caddyserver/caddy/v2'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-5r3v-vc8m-m96g",
  "modified" : "2026-02-27T19:55:10Z",
  "published" : "2026-02-24T20:39:08Z",
  "aliases" : [ "CVE-2026-27590" ],
  "summary" : "Caddy: Unicode case-folding length expansion causes incorrect split_path index in FastCGI transport",
  "details" : "### Summary\n\nCaddy'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).\n\n### Details\n\nThe issue is in `github.com/caddyserver/caddy/modules/caddyhttp/fastcgi.Trasnport.splitPos()` (and the subsequent slicing in `buildEnv()`):\n\n```\nlowerPath := strings.ToLower(path)\nidx := strings.Index(lowerPath, strings.ToLower(split))\nreturn idx + len(split)\n```\n\nThe returned index is computed in the byte space of lowerPath, but `buildEnv()` applies it to the original path:\n\n- `docURI = path[:splitPos]`\n- `pathInfo = path[splitPos:]`\n- `scriptName = strings.TrimSuffix(path, fc.pathInfo)`\n- `scriptFilename = caddyhttp.SanitizedPathJoin(fc.documentRoot, fc.scriptName)`\n\nThis 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.\n\n### PoC\n\nCreate 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):\n\n1. Save this as `poc.go`:\n\n```go\npackage main\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n)\n\nfunc splitPos(path string, split string) int {\n\tlowerPath := strings.ToLower(path)\n\tidx := strings.Index(lowerPath, strings.ToLower(split))\n\tif idx < 0 {\n\t\treturn -1\n\t}\n\treturn idx + len(split)\n}\n\nfunc main() {\n\t// U+023A: Ⱥ (UTF-8: C8 BA). Lowercase is ⱥ (UTF-8: E2 B1 A5), longer in bytes.\n\tpath := \"/ȺȺȺȺshell.php.txt.php\"\n\tsplit := \".php\"\n\n\tpos := splitPos(path, split)\n\n\tfmt.Printf(\"orig bytes=%d\\n\", len(path))\n\tfmt.Printf(\"lower bytes=%d\\n\", len(strings.ToLower(path)))\n\tfmt.Printf(\"splitPos=%d\\n\", pos)\n\n\tfmt.Printf(\"orig[:pos]=%q\\n\", path[:pos])\n\tfmt.Printf(\"orig[pos:]=%q\\n\", path[pos:])\n\n\t// Expected split: right after the first \".php\" in the original string\n\twant := strings.Index(path, split) + len(split)\n\tfmt.Printf(\"expected splitPos=%d\\n\", want)\n\tfmt.Printf(\"expected orig[:]=%q\\n\", path[:want])\n}\n```\n\n2. Run it:\n\n```console\ngo run poc.go\n```\n\nOutput on my side:\n\n```\norig bytes=26\nlower bytes=30\nsplitPos=22\norig[:pos]=\"/ȺȺȺȺshell.php.txt\"\norig[pos:]=\".php\"\nexpected splitPos=18\nexpected orig[:]=\"/ȺȺȺȺshell.php\"\n```\n\nExpected split is right after the first `.php` (`/ȺȺȺȺshell.php`). Instead, the computed split lands later and cuts the original path after `shell.php.txt`, leaving `.php` as the remainder.\n\n### Impact\n\nSecurity boundary bypass/path confusion in script resolution.\nIn typical deployments, `.php` extension boundaries are relied on to decide what is executed by PHP. This bug can cause Caddy/FPM to execute a different file than intended by confusing `SCRIPT_NAME`/`SCRIPT_FILENAME`. If an attacker can place attacker-controlled content into a file that can be resolved as `SCRIPT_FILENAME` (common in web apps with uploads or writable directories), this can lead to unintended PHP execution of non-.php files and potentially remote code execution. Severity depends on deployment and presence of attacker-controlled file writes, but the primitive itself is remotely triggerable via crafted URLs.\n\nThis vulnerability was initially reported to FrankenPHP (https://github.com/php/frankenphp/security/advisories/GHSA-g966-83w7-6w38) by @AbdrrahimDahmani. The affected code has been copied/adapted from Caddy, which, according to research, is also affected.\n\nThe patch is a port of the FrankenPHP patch.",
  "severity" : [ {
    "type" : "CVSS_V4",
    "score" : "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:P"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "Go",
      "name" : "github.com/caddyserver/caddy/v2"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "2.11.1"
      } ]
    } ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/caddyserver/caddy/security/advisories/GHSA-5r3v-vc8m-m96g"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/php/frankenphp/security/advisories/GHSA-g966-83w7-6w38"
  }, {
    "type" : "ADVISORY",
    "url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-27590"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/caddyserver/caddy"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/caddyserver/caddy/releases/tag/v2.11.1"
  }, {
    "type" : "WEB",
    "url" : "https://pkg.go.dev/vuln/GO-2026-4536"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-20" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-02-24T20:39:08Z",
    "nvd_published_at" : "2026-02-24T17:29:04Z"
  }
}