GITHUB.COM/CADDYSERVER/CADDY/V2 · GHSA-69hv-5jfc-mvmv · Runtime
FIX → 2.11.8
Caddy's rewrite handler inserts the file matcher remainder without escaping and then percent-decodes it again, so a double-encoded path such as `..%252f` becomes `../`. An unauthenticated client can read static files that a path-based deny rule was meant to block, and can alter the query string or path the backend receives.
first public Nov 4, 2026 · 1 source
Could this affect your app?
Free first check from your website. Connect your repo for the full answer.
Sites running Caddy 2.11.7 or earlier that rewrite requests to {http.matchers.file.remainder}, typically PHP-style path info set up by try_files with split_path, and that protect paths with a deny rule placed before that rewrite. Configurations without such a rewrite, or without a path-based deny rule, are not exposed to the file-read bypass.
Not affected if managed-hosting, no-path-deny-rule, no-remainder-rewrite, static-export
Who cares caddy, frankenphp, go, php-server, self-hosted
caddy version and compare against the fixed release.rewrite directives that use {http.matchers.file.remainder}, usually paired with try_files and split_path (the pattern FrankenPHP's generates).The rewrite handler inserts {http.matchers.file.remainder} unescaped, then percent-decodes the rewritten URI again after the matchers already ran. A double-encoded path info such as ..%252f becomes ../ and reaches files a path-based deny rule protects; %3F and %23 in the path info replace the query string or truncate the path.
{http.matchers.file.remainder} holds decoded path text. escapePathPlaceholders() (modules/caddyhttp/rewrite/rewrite.go) escapes {http.request.uri.path} and {http.matchers.file.relative} before the rewritten URI is parsed, but not the remainder. The rewrite handler inserts it raw, cuts the result at # and ?, and percent-decodes it a second time, after the matchers that ran on the original path.
Check it with your coding agent
It reads this page and tells you if your code is affected.
Are you affected?
Ask from here. InfraStack checks the affected versions against your stack and answers in your inbox.
InfraStack checks the affected versions against your stack and answers in Slack, with the evidence and the fix.
php_serverpath or file deny rule sits before such a rewrite.Upgrade to 2.11.8
github.com/caddyserver/caddy/v2): upgrade to 2.11.8 or later; affected versions are <= 2.11.7.try_files with split_path and a rewrite to {http.matchers.file.relative}{http.matchers.file.remainder} is the usual setup for PHP-style path info (FrankenPHP's php_server generated it).
Fix: escape the remainder with escapePathPreservingSlashes(), like {http.matchers.file.relative}. A remainder without reserved characters, such as /foo/bar, is rewritten as before. Patch: public PR from dunglas:rewrite-escape-file-remainder.
Discovered by Anthropic's OSS Scanner, as vulnerability ANT-2026-32RAL55Q.
public/index.php and public/private/secret.txt exist:
:8080 {
root * ./public
route {
@denied path /private/*
error @denied 403
@php file {
try_files {path} index.php
split_path .php
}
rewrite @php {http.matchers.file.relative}{http.matchers.file.remainder}
@phpfiles path *.php /index.php/*
respond @phpfiles "PHP route: {uri}" 200
file_server
}
}
$ curl 'http://localhost:8080/index.php/..%252fprivate%252fsecret.txt'
SECRET
$ curl 'http://localhost:8080/index.php/x%23y?a=b'
PHP route: /index.php/x?a=b
$ curl 'http://localhost:8080/index.php/x%253Fa=evil?a=b'
PHP route: /index.php/x%3Fa=evil?a=b
The first request is a single opaque segment for @denied. After the rewrite it becomes /index.php/../private/secret.txt, misses the PHP route and file_server serves the protected file.
Unauthenticated clients read static files that a path-based rule placed before the rewrite is meant to deny, on sites that rewrite to {http.matchers.file.remainder} (PHP-style path info). They can also change the query string or path the backend receives.