HTTP/2 multiple Cookie headers not concatenated when proxied to HTTP/1.1 upstream (RFC 9113 §8.2.3)
I maintainer di solito rispondono entro 7 giorni
Valutazione
- Difficoltà
- 3/5
- Tempo stimato
- 1-2 giorni
- Idoneità per principianti
- 68/100
- Tipo di issue
- Bug
- Chiarezza
- Abbastanza chiara
- Stato di attività
- Attiva
- Stack tecnologico
- rust
- Ambito
- backend-api-design, networking
Direzione di ricerca
Inizia in pingora-proxy/src/proxy_h1.rs, in proxy_to_h1_upstream(), quindi esamina pingora-http/src/lib.rs per l’API RequestHeader. Riproduci la richiesta HTTP/2 con campi Cookie separati e traccia il modo in cui gli header vengono scritti verso l’upstream HTTP/1.1. Il lavoro è completato quando più campi Cookie arrivano all’upstream come un unico header unito con "; ", mentre gli altri header rimangono invariati.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
Summary
When Pingora receives an HTTP/2 request containing multiple Cookie header fields (which is valid per RFC 9113 §8.2.3) and proxies it to an HTTP/1.1 upstream, the individual Cookie headers are forwarded as separate header lines instead of being concatenated into a single header using "; " as the delimiter.
This violates RFC 9113 §8.2.3, which states:
If there are multiple Cookie header fields after decompression, these MUST be concatenated into a single octet string using the two-octet delimiter of
0x3B, 0x20(the ASCII string"; ") before being passed into a non-HTTP/2 context, such as an HTTP/1.1 connection...
Reproduction
Environment:
- Pingora proxy with H2 enabled on the downstream side
- HTTP/1.1 upstream backend
Steps:
- Configure a Pingora-based proxy accepting HTTP/2 from clients
- Set up an HTTP/1.1 backend that logs or inspects raw request headers
- Send an HTTP/2 request with multiple
Cookieheader fields:
# Using nghttp to send separate cookie headers over HTTP/2
nghttp -v -H "cookie: session_id=abc123" -H "cookie: preferred_language=en" https://proxy-host/path
- Observe the headers received by the HTTP/1.1 upstream
Expected behavior:
The upstream receives a single Cookie header:
Cookie: session_id=abc123; preferred_language=en
Actual behavior:
The upstream receives two separate Cookie headers:
Cookie: session_id=abc123
Cookie: preferred_language=en
Real-world impact
Modern browsers (Chrome, Firefox, Safari) routinely split cookies into multiple cookie header fields when communicating over HTTP/2, as permitted by RFC 9113 §8.2.3 for improved HPACK compression.
Many HTTP/1.1 backend frameworks only read the first Cookie header, causing session cookies to be silently dropped. A concrete example:
- GitLab (Rails): When proxied through an H2-accepting reverse proxy, the
_gitlab_sessioncookie is lost if it arrives in a separate header line from other cookies. This causes CSRF token verification to fail, resulting in HTTP 422 on all state-changing requests (form submissions, API calls with CSRF protection).
This affects any backend that does not handle multiple Cookie headers, which is the majority of HTTP/1.1 implementations since RFC 6265 §5.4 specifies that the user agent "MUST NOT attach more than one header field named Cookie."
Workaround
Disable HTTP/2 on the downstream side so clients send cookies as a single header line (HTTP/1.1 behavior).
Suggested fixes
Fix A: Cookie header concatenation (RFC-compliant)
The concatenation should occur in the H2-to-H1.1 proxy path. Based on code review, the most appropriate location appears to be:
pingora-proxy/src/proxy_h1.rs— inproxy_to_h1_upstream(), before writing headers to the upstream connection- Alternatively,
pingora-http/src/lib.rs— as aRequestHeadermethod (e.g.,concatenate_cookie_headers()) callable from the proxy layer
The logic would be:
// Pseudocode
if downstream_is_h2 && upstream_is_h1 {
let cookies: Vec<&[u8]> = request.headers.get_all("cookie")
.iter()
.map(|v| v.as_bytes())
.collect();
if cookies.len() > 1 {
let merged = cookies.join(b"; ");
request.headers.remove("cookie");
request.headers.insert("cookie", merged);
}
}
Fix B: ALPN-based protocol matching (avoids conversion entirely)
An alternative architectural approach is to eliminate the H2→H1.1 conversion by matching client-side and upstream-side protocols via ALPN negotiation. We used this strategy in a production MITM proxy and it eliminates an entire class of header translation bugs, not just cookies.
The approach mirrors Chromium's RequiresHTTP11() mechanism:
┌──────────────────────────────────────┐
│ Protocol Cache │
│ domain → { Http2 | Http11Only | │
│ AlpnFailed | 421 } │
└──────────┬───────────────────────────┘
│
Client ──TLS──► Proxy ──TLS──► Upstream
ALPN ALPN
│ │
▼ ▼
┌─────────────────────────────────────────────────┐
│ Step 1: Upstream ALPN → discover protocol │
│ Step 2: If mismatch → cache + GOAWAY/close │
│ Step 3: Client reconnects │
│ Step 4: Check cache → force client ALPN │
│ Step 5: Both sides same protocol → no convert │
└─────────────────────────────────────────────────┘
Key implementation details:
-
Protocol cache — Per-domain cache storing upstream protocol support. Entries track
AlpnFailed,Http11Only,MisdirectedRequest(421), with TTL-based expiry and failure threshold counting. -
Client ALPN enforcement — Before the client TLS handshake, check the protocol cache. If the upstream is known to be H1.1-only, advertise only
http/1.1in the client-side ALPN so the browser never negotiates H2 for that domain. -
Mismatch recovery — When a mismatch is detected (client H2 but upstream H1.1):
- Record the failure in the protocol cache
- Terminate the connection (H2: GOAWAY frame, H1.1: close)
- Browser automatically reconnects → cache forces H1.1 ALPN → both sides match
-
Strict match enforcement — The proxy only operates in matched-protocol mode:
match (client_protocol, upstream_protocol) { (Http2, Http2) => { /* H2 pass-through: headers forwarded as-is */ } (Http11, Http11) => { /* H1.1 pass-through: headers forwarded as-is */ } _ => { /* Should not reach here — handled by ALPN logic */ } }
Advantages:
- Eliminates all H2↔H1.1 translation bugs (not just Cookie, but also pseudo-header stripping, Transfer-Encoding conflicts, etc.)
- Zero-cost at steady state (cache hit → correct ALPN from the start)
- First-request penalty is one extra round-trip (connection drop + reconnect), amortized by cache
Tradeoffs:
- Domains that only support H1.1 will always use H1.1 end-to-end (no H2 multiplexing benefit between client and proxy)
- Requires per-domain protocol cache management (TTL expiry, failure counting)
We validated this approach against 50+ production sites (banking, e-commerce, SaaS) including the GitLab CSRF scenario described above, with zero cookie-related failures after deployment.
Prior art
This is a known issue across the HTTP proxy ecosystem:
| Project | Issue | Status |
|---|---|---|
| hyperium/hyper | #2528 | Open (2021) |
| python-hyper/h2 | #497 | Fixed (added normalize_inbound_headers) |
| HAProxy | discourse thread | Workaround via Lua |
| Varnish Cache | #2291 | Manual std.collect() |
| imbolc/tower-cookies | #8 | Fixed |
| yesodweb/wai (Haskell) | #486 | Open |
| httpwg/http-extensions | #2541 | RFC-level discussion |
Note
Pingora v0.4.0 already addressed the reverse direction — Set-Cookie response header casing during H2→H1.1 downgrade (changelog). This issue covers the complementary request-direction Cookie header concatenation.
References
- Lingua principale
- Rust
- Stelle
- 27.8k
- Fork
- 1.8k
- Merge medio
- 21h 26m
- PR unite (30g)
- 4
Preparare l'ambiente
- Include un Dockerfile o un file Docker Compose
- Nessun modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di cloudflare/pingora
-
HeaderSerde round-trip drops HTTP version and custom reason phraseForse già presa @hakarrr l’ha presa 10 giorni fa. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 82/100
cloudflare/pingora#1064 ·
I maintainer di solito rispondono entro 7 giorni
-
range_header_filter ignores Range when the range count equals max_multipart_rangesForse già presa @hakarrr l’ha presa 8 giorni fa. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 80/100
cloudflare/pingora#1063 ·
I maintainer di solito rispondono entro 7 giorni
-
HTTP/1 body writer does not treat 205 Reset Content as bodylessForse già presa @harshbhx l’ha presa 16 giorni fa. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 75/100
cloudflare/pingora#1055 ·
I maintainer di solito rispondono entro 7 giorni
-
msrv: MSRV is now 1.88.0Forse già presa @Devel08 l’ha presa 20 giorni fa. Aperta
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 85/100
cloudflare/pingora#1053 ·
I maintainer di solito rispondono entro 7 giorni
-
pingora-limits: Estimator::incr panics on overflow with overflow checks enabledForse di nuovo libera Una pull request per questa issue è stata chiusa senza essere unita. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 90/100
cloudflare/pingora#1040 ·
I maintainer di solito rispondono entro 7 giorni
Tutte le issue di cloudflare/pingora
Issue simili
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
I maintainer di solito rispondono entro 2 giorni
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 65/100
I maintainer di solito rispondono entro 2 giorni
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 68/100
aaif-goose/goose#13364 ·
I maintainer di solito rispondono entro 1 giorno
-
ai_p2
Difficoltà 2/5 1-3 ore Idoneità per principianti 82/100
ClickHouse/ClickHouse#130006 ·
I maintainer di solito rispondono entro 1 giorno
-
[Bug]: PostgreSQL backend: Queue.remove() / Job.remove() never emit the QueueEvents "removed" eventForse già presa @1AbdullahButt l’ha presa oggi. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 84/100
taskforcesh/bullmq#5085 ·
I maintainer di solito rispondono entro 1 giorno