GITHUB.COM/CADDYSERVER/CADDY/V2 · GHSA-f278-mgc7-46j3 · Runtime
FIX → v2.11.8
When a php_fastcgi site has no index.php file, Caddy's access rules check a cleaned path while PHP executes the raw path, so a request with dot segments runs a PHP script that a rule was protecting. With an attacker-controlled .php file on disk, the same flaw can lead to code execution as the PHP user.
first public Nov 4, 2026 · 1 source
Could this affect your app?
Free first check from your website. Connect your repo for the full answer.
Any site served by Caddy with php_fastcgi where the site root lacks an index.php file, including stock php_fastcgi with no options and configurations that set index off. Sites with a real index.php and no custom routing that skips the rewrite are not affected per the report. Caddy itself is affected only when the request reaches the FastCGI transport.
Not affected if index-php-present, managed-hosting, no-php-fastcgi
Who cares caddy, php, php-fpm, self-hosted
php_fastcgi in the Caddyfile and check whether the site root has an index.php file (or the file named by index).caddy version and compare against the affected range.index off is set on php_fastcgi.An unauthenticated attacker can execute a PHP script that a Caddyfile access-control rule was protecting.
Disclosure note: This finding was identified and validated by Bynario AI, an automated pipeline. The technical claims and supporting evidence were manually reviewed before submission. The technical report was partly written using AI and re-written by me.
Caddy's route matchers evaluate the path.Clean() cleaned request path, while the FastCGI transport derives SCRIPT_FILENAME from the raw path. The two therefore disagree about which file a request names: the access-control decision is made about one file, and a different file is executed.
If there is no index.php file, GET /admin.php/../../nope.php or /admin.php/..%2f..%2fnope.php is judged by every matcher as a request for /nope.php. A basic_auth or handle gate on /admin*, /admin.php, or /admin.php* never fires while php-fpm is told to execute .
Check it with your coding agent
It reads this page and tells you if your code is affected.
Are you affected?
Ask from here. InfraStack checks the affected versions against your stack and answers in your inbox.
InfraStack checks the affected versions against your stack and answers in Slack, with the evidence and the fix.
Upgrade to v2.11.8
github.com/caddyserver/caddy/v2: move to v2.11.8 or later (fixed in v2.11.8)/srv/admin.phpNo access-control configuration prevents this. The matcher only ever sees a string the attacker chooses fresh on every request, so no path rule can cover it.
Where the attacker also controls a .php file on disk (e.g. an upload directory the operator deliberately excluded from PHP execution), this escalates from authorization bypass to remote code execution as the php-fpm user.
php_fastcgi expands to three routes (caddy adapt output abbreviated):
route 1
{ "match": [{"file": {"try_files": ["{http.request.uri.path}/index.php"]}, ...}],
"handle": [{"handler": "static_response", "status_code": 308}] }
route 2
{ "match": [{"file": {"try_files": ["{http.request.uri.path}",
"{http.request.uri.path}/index.php",
"index.php"],
"try_policy": "first_exist_fallback",
"split_path": [".php"]}}],
"handle": [{"handler": "rewrite", "uri": "{http.matchers.file.relative}"}] }
route 3
{ "match": [{"path": ["*.php"]}],
"handle": [{"handler": "reverse_proxy",
"transport": {"protocol": "fastcgi", "split_path": [".php"]}}] }
Caddy never rewrites r.URL.Path itself. CleanPath is applied only to matcher-local copies for their own yes/no decisions (matchers.go:436,487, urlpatternmatcher.go:118). The rewrite in route 2 is therefore the only step in the entire chain that normalizes the path the transport will later read. But as said, that part never happens, so transport will receive the entire raw path.
Bug 1: the transport splits an un-normalized path
modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go:288
fpath := r.URL.Path // raw; contains ".." as sent by the client
...
if splitPos := t.splitPos(fpath); splitPos > -1 {
docURI = fpath[:splitPos]
pathInfo = fpath[splitPos:]
scriptName = strings.TrimSuffix(scriptName, pathInfo)
}
scriptFilename := caddyhttp.SanitizedPathJoin(root, scriptName)
splitPos returns the index past the end of the first token, so for /admin.php/../../nope.php scriptName is /admin.php and pathInfo becomes /../../nope.php. Both get sanitized, but after the split happens.
What php-fpm will end up executing is SCRIPT_FILENAME = scriptFilename, which is /srv/admin.php.
Meanwhile the path matcher evaluates the cleaned path = /nope.php, so a basic_auth or handle gate on /admin.php or /admin* never matches, and never runs.
Bug 2: first_exist_fallback does not fall back.
modules/caddyhttp/fileserver/matcher.go:440-461
for i, pattern := range m.TryFiles {
candidates := makeCandidates(pattern)
for _, c := range candidates {
// Skip the IO if using fallback policy and it's the latest item
if i == maxI {
setPlaceholders(c, false)
return true, nil
}
if info, exists := m.strictFileExists(fileSystem, c.fullpath); exists { ... }
}
}
// falls through to: return false, nil
The fallback shortcut is nested inside the candidate loop, and makeCandidates builds its list from fs.Glob (matcher.go:396-414), which returns only paths that exist. When the final try_files entry (index.php) is absent, candidates is empty, the loop body never executes, the fallback is unreachable, and selectFile returns false.
Four points indicating this is unintended rather than by design:
efd9251a (#6699) specifically to match unconditionally, and php_fastcgi hard-codes it (fastcgi/caddyfile.go:320) precisely so the rewrite always runs.makeCandidates already performed the stat and discarded the candidate when the file was missing.matcher.go:395-396 sets globResults = []string{fullPattern} with no existence check, so candidates is never empty and the fallback does fire. Identical configuration, different security outcome on Linux vs Windows.matcher_test.go contains no fallback case; the caddyfile_adapt fixtures assert only generated JSON.Documented behaviour vs. implementation. https://caddyserver.com/docs/caddyfile/directives/php_fastcgi
The documentation states a guarantee that Defect 2 breaks:
"Lastly, it'll always rewrite to
index.php(it almost always exists for modern PHP apps)."
It does not always rewrite. When index.php is absent it rewrites nothing at all, and the un-normalized path proceeds to the transport.
The index subdirective is documented in the same terms:
"Can be set to
offto disable rewrite fallback toindex.phpwhen a matching file is not found."
"when a matching file is not found" is precisely the condition under which the fallback fails to fire. The documented contract and the implementation disagree about the only case that matters.
The expanded form shown in the docs is presented as exactly equivalent:
"The
php_fastcgidirective (without subdirectives) is the same as the following configuration."
and that published expansion is the vulnerable structure: a file matcher with try_policy first_exist_fallback guarding the sole rewrite, followed by a reverse_proxy with transport fastcgi. An operator who copies the documented expansion verbatim, or who writes the one-line shortcut it stands for, is affected whenever the index file is absent.
There is no documented configuration that avoids this; nothing in the page warns that the rewrite may be skipped, and Caddy emits no warning at config load.
Note that index is an optional subdirective and defaults to index.php. If an operator decides to change their index (e.g. index home.php), they also need to make sure it is done within the bounds of the php_fastcgi directive, otherwise the configuration remains vulnerable.
How they chain
With no index file present, route 2's matcher answers "no", nothing modifies r.URL.Path, and route 3's transport splits the client's original bytes. Route 3's own path *.php matcher still matches, because it evaluates the cleaned /nope.php, which does end in .php.
Tested on commit d6637934e866e34f5676210f8a409b139098f954 on MacOS and Linux.
Two containers on a private Docker network. php-fpm is not published, so the only route to the interpreter is through Caddy.
/srv/admin.php — the script the policy protects:
<?php
echo "ADMIN PANEL\n";
echo "SECRET=admin-token-4417\n";
echo "REMOTE_USER=" . ($_SERVER['REMOTE_USER'] ?? '(none - NOT AUTHENTICATED)') . "\n";
echo "SCRIPT_FILENAME=" . $_SERVER['SCRIPT_FILENAME'] . "\n";
/srv/home.php or any second file. Do not create /srv/index.php.
C