Skip to content

H1 upstream: chunked terminator 0\r\n\r\n written twice when an H2 downstream ends the request body with an empty DATA frame (END_STREAM), poisoning upstream keep-alive connections #935

Description

@songhieu

Summary

When proxying HTTP/2 downstream → HTTP/1.1 upstream, if the client finishes the request body with an empty DATA frame carrying END_STREAM (very common: Cloudflare's HTTP/2 to Origin does this, so does curl -T - and most streaming clients that don't know body length up front), pingora writes the chunked terminating sequence 0\r\n\r\n to the upstream twice.

Strict HTTP/1.1 parsers on the upstream (e.g. uvicorn/h11, which rejects with 400 Invalid HTTP request received.) parse the duplicate terminator as the start of a bogus pipelined request. The upstream connection is then desynchronized: the stray error response is delivered to whichever request reuses the pooled keep-alive connection next, so unrelated requests intermittently receive 400s, and the connection pool churns (Upstream body is already finished. Nothing to write / Data received on idle client connection, close it warnings flood the logs).

Verified on pingora-proxy 0.6.0; by code inspection the same gap is still present on current main.

Mechanism

  1. proxy_h1.rs — the downstream read path deliberately forwards a final empty chunk when end_of_body is true (the guard only skips empty chunks mid-stream):
/* It is normal to get 0 bytes because of multi-chunk ...
 * Don't write 0 bytes to the network since it will be
 * treated as the terminating chunk */
if !upstream_end_of_body && data.as_ref().is_some_and(|d| d.is_empty()) {
    return Ok(false);
}
  1. proxy_h1.rs::send_body_to1 — the empty body slice is passed to write_body, then finish_body runs because end == true:
HttpTask::Body(data, end) => {
    body_done = end;
    if let Some(d) = data {
        let m = client_session.write_body(&d).await;   // <-- d is empty
        ...
    }
}
...
if body_done {
    match client_session.finish_body().await {         // <-- terminator again
  1. pingora-core/src/protocols/http/v1/body.rs::do_write_chunked_body has no empty-buffer guard, so a 0-byte write is the terminator:
let chunk_size = buf.len();                            // 0
let chuck_size_buf = format!("{:X}\r\n", chunk_size);  // "0\r\n"
let mut output_buf = Bytes::from(chuck_size_buf).chain(buf).chain(&b"\r\n"[..]);
// wire: 0\r\n\r\n  == chunked terminator
self.body_mode = BM::ChunkedEncoding(written + chunk_size); // stays ChunkedEncoding

Because body_mode stays ChunkedEncoding, the subsequent finish_body()do_finish_chunked_body() writes LAST_CHUNK (0\r\n\r\n) a second time.

Reproduction

Any ProxyHttp service with an h2-enabled TLS listener and a plain-HTTP/1.1 upstream. Point the upstream at a byte-capture sink:

nc -l 9999 > captured.txt        # upstream: capture raw bytes

# client: HTTP/2, body streamed from stdin => no content-length,
# curl ends the stream with an empty DATA frame + END_STREAM
printf '%s' '{"hello":"world"}' | \
  curl -sk --http2 -X POST -T - https://localhost:8443/test \
       -H 'Content-Type: application/json'

Captured upstream bytes (pingora 0.6.0):

POST /test HTTP/1.1
user-agent: curl/8.7.1
accept: */*
Content-Type: application/json
Transfer-Encoding: chunked
Host: localhost:8443

11
{"hello":"world"}
0

0

Note the two 0\r\n\r\n sequences. The same request sent as HTTP/1.1 chunked (-H 'Transfer-Encoding: chunked', no -T) produces a single terminator — the difference is the trailing empty DATA frame that only the h2 path delivers as Body(Some(empty), end=true).

Real-world trigger: Cloudflare HTTP/2 to Origin streams POST bodies this way, so every such request through a pingora-based origin proxy corrupts the upstream connection.

Suggested fix

Either (or both):

  • send_body_to1: skip the write for empty data — finish_body() alone terminates correctly:
if let Some(d) = data {
    if !d.is_empty() {
        let m = client_session.write_body(&d).await;
        ...
    }
}
  • BodyWriter::do_write_chunked_body: return early on an empty buffer so a 0-byte application write can never emit the protocol terminator.

Workaround for users

Drop the final empty chunk in ProxyHttp::request_body_filter:

async fn request_body_filter(
    &self,
    _session: &mut Session,
    body: &mut Option<Bytes>,
    end_of_stream: bool,
    _ctx: &mut Self::CTX,
) -> Result<()> {
    // Only when end_of_stream: an empty mid-stream chunk turned into None
    // would falsely signal end-of-body in proxy_h1.
    if end_of_stream && body.as_ref().is_some_and(|b| b.is_empty()) {
        *body = None;
    }
    Ok(())
}

Environment

  • pingora / pingora-proxy / pingora-core 0.6.0 (boringssl feature), Linux x86_64
  • Downstream: HTTP/2 over TLS (ALPN h2), e.g. Cloudflare HTTP/2-to-Origin or curl --http2
  • Upstream: HTTP/1.1 cleartext (gunicorn + uvicorn worker; reproduced with raw nc byte capture)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions