HTTP/2 multiple Cookie headers not concatenated when proxied to HTTP/1.1 upstream (RFC 9113 §8.2.3)
Los mantenedores suelen responder en 7 días
@MyLittleLuckyDog ya está trabajando en esto.
Desde el 2/6/2026.
- #901 de @MyLittleLuckyDog — abierto
Evaluación
- Dificultad
- 3/5
- Tiempo estimado
- 1-2 días
- Aptitud para principiantes
- 68/100
- Tipo de issue
- Error
- Claridad
- Bastante claro
- Estado de actividad
- Activo
- Stack tecnológico
- rust
- Área
- backend-api-design, networking
Línea de trabajo
Comienza en pingora-proxy/src/proxy_h1.rs, en proxy_to_h1_upstream(), y luego inspecciona pingora-http/src/lib.rs para consultar la API de RequestHeader. Reproduce la solicitud HTTP/2 con campos Cookie separados y rastrea cómo se escriben los headers en el upstream HTTP/1.1. Se considera terminado cuando varios campos Cookie llegan al upstream como un solo header unidos con "; ", mientras que los demás headers permanecen sin cambios.
Escrito por el modelo de indexación a partir del texto del issue.
Descripción
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
- Lenguaje dominante
- Rust
- Estrellas
- 27.8k
- Forks
- 1.8k
- Merge medio
- 21 h 26 min
- PR fusionados (30 d)
- 4
Preparar el entorno
- Incluye un Dockerfile o un archivo de Docker Compose
- Sin plantilla de pull request
- Leer la guía de contribución
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de cloudflare/pingora
-
HeaderSerde round-trip drops HTTP version and custom reason phrasePosiblemente ocupada @hakarrr la tomó hace 10 días. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 82/100
cloudflare/pingora#1064 ·
Los mantenedores suelen responder en 7 días
-
range_header_filter ignores Range when the range count equals max_multipart_rangesPosiblemente ocupada @hakarrr la tomó hace 8 días. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 80/100
cloudflare/pingora#1063 ·
Los mantenedores suelen responder en 7 días
-
HTTP/1 body writer does not treat 205 Reset Content as bodylessPosiblemente ocupada @harshbhx la tomó hace 16 días. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 75/100
cloudflare/pingora#1055 ·
Los mantenedores suelen responder en 7 días
-
msrv: MSRV is now 1.88.0Posiblemente ocupada @Devel08 la tomó hace 20 días. Abierto
Dificultad 1/5 Menos de una hora Aptitud para principiantes 85/100
cloudflare/pingora#1053 ·
Los mantenedores suelen responder en 7 días
-
pingora-limits: Estimator::incr panics on overflow with overflow checks enabledQuizá libre de nuevo Un pull request para esta issue se cerró sin fusionarse. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 90/100
cloudflare/pingora#1040 ·
Los mantenedores suelen responder en 7 días
Todos los issues de cloudflare/pingora
Issues similares
-
ai_p2
Dificultad 2/5 1-3 horas Aptitud para principiantes 82/100
ClickHouse/ClickHouse#130006 ·
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 78/100
horizon-rust/horizon#12071 ·
Los mantenedores suelen responder en 1 día
-
bug priority: low
Dificultad 2/5 1-3 horas Aptitud para principiantes 78/100
storytold/photocraft#12183 · 1 comentario ·
Los mantenedores suelen responder en 2 días
-
area:render needs-triage
Dificultad 2/5 1-3 horas Aptitud para principiantes 78/100
bherbruck/solvecraft#13138 ·
-
testing
Dificultad 2/5 1-3 horas Aptitud para principiantes 65/100