HTTP/2 multiple Cookie headers not concatenated when proxied to HTTP/1.1 upstream (RFC 9113 §8.2.3)
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
Source: cloudflare/pingora