---
id: "GHSA-f278-mgc7-46j3"
page: "https://infrastack.ai/live/GHSA-f278-mgc7-46j3"
kind: "vulnerability"
aliases: ["GHSA-f278-mgc7-46j3"]
title: "php_fastcgi authorization bypass via dot-segments in the request path"
severity: "medium"
cvss: 5.9
epss: null
exploited: false
status: "open"
category: "runtime"
products: [{"name": "github.com/caddyserver/caddy/v2", "fixed": ["v2.11.8"], "affected": ">= 2.4.6, <= 2.11.7", "ecosystem": "go"}]
ecosystems: ["go"]
tags: ["caddy", "cwe-436", "cwe-863", "php", "php-fpm", "runtime", "self-hosted"]
not_affected_if: ["index-php-present", "managed-hosting", "no-php-fastcgi"]
published_at: "2026-11-04T01:11:26+00:00"
first_seen_at: "2026-11-09T22:53:33+00:00"
updated_at: "2026-11-09T22:55:14+00:00"
sources: [{"source": "ghsa-repo", "url": "https://github.com/caddyserver/caddy/security/advisories/GHSA-f278-mgc7-46j3", "first_seen_at": "2026-11-09T22:53:33+00:00"}]
exploit_sources: []
---

# php_fastcgi authorization bypass via dot-segments in the request path

Link for people: https://infrastack.ai/live/GHSA-f278-mgc7-46j3 (this .md is for agents)

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.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- go:github.com/caddyserver/caddy/v2 affected >= 2.4.6, <= 2.11.7 fixed v2.11.8

## How to check

- Look for `php_fastcgi` in the Caddyfile and check whether the site root has an `index.php` file (or the file named by `index`).
- Check the Caddy version with `caddy version` and compare against the affected range.
- Check whether `index off` is set on `php_fastcgi`.

## Fix

- `github.com/caddyserver/caddy/v2`: move to `v2.11.8` or later (fixed in `v2.11.8`)

## Timeline

- 2026-11-09T22:53:33+00:00 ghsa-repo https://github.com/caddyserver/caddy/security/advisories/GHSA-f278-mgc7-46j3

## Documents

- [advisory] Maintainer's advisory: github.com https://github.com/caddyserver/caddy/security/advisories/GHSA-f278-mgc7-46j3

## From the sources

### Summary
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 `/srv/admin.php`.

No 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.

### Details

`php_fastcgi` expands to three routes (`caddy adapt` output abbreviated):

``` json
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:

* The policy was introduced in `efd9251a` (#6699) specifically to match unconditionally, and `php_fastcgi` hard-codes it (`fastcgi/caddyfile.go:320`) precisely so the rewrite always runs.
* The branch comment says "Skip the IO" but `makeCandidates` already performed the stat and discarded the candidate when the file was missing.
* It is **platform-divergent**: on Windows `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.
* There is no runtime test coverage: `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 `off` to disable rewrite **fallback** to `index.php` when 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_fastcgi` directive (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`.

### PoC
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

---

_Free to read, with fair use of about 60 pages a minute. For higher limits or API access: sales@infrastack.ai_
