Skip to content

Egress interceptor close-delimits no-Content-Length responses (drops chunked framing, no TLS close_notify) → breaks strict TLS (rustls) clients #220

Description

@juanibiapina

Summary

With interceptHttps = true, the Containers egress interceptor MITM-terminates the container's outbound TLS. When the upstream response has no Content-Length (for example an uncompressed response the origin streams via Transfer-Encoding: chunked or HTTP/2), the interceptor delivers it to the in-container client as a connection-close-delimited HTTP/1.1 response (Connection: close, no Content-Length, no chunked terminator) and then closes the TLS connection without a close_notify alert.

Lenient clients (curl, Go net/http, Node undici) tolerate this and work. rustls clients reject it with UnexpectedEof: peer closed connection without sending TLS close_notify. This breaks Rust tooling run inside containers, for example the Google Workspace CLI (gws, reqwest/rustls).

The chunked framing the interceptor receives from the worker is self-terminating, but it is dropped on the way to the container and replaced with close-delimiting. Responses that carry a Content-Length (including compressed ones) are relayed length-delimited and work.

Environment

  • @cloudflare/containers 0.3.5
  • wrangler 4.97.0
  • interceptHttps = true, catch-all outbound handler forwarding via fetch

Reproduced on a deployed container with a trivial (req) => fetch(req) pass-through outbound handler. The worker-side log shows a Transfer-Encoding: chunked response; the in-container client receives it with Connection: close, no Content-Length, and no chunked terminator.

Minimal repro

Container class with HTTPS interception and a pass-through outbound handler:

import { Container, getContainer } from "@cloudflare/containers";

export class MyContainer extends Container {
  defaultPort = 8080;
  interceptHttps = true;
}
// Catch-all: forward unchanged.
MyContainer.outbound = (req) => fetch(req);

export default {
  fetch(req: Request, env: any) {
    return getContainer(env.MY_CONTAINER).fetch(req);
  },
};

Use any image with curl (and the Cloudflare CA installed per the docs). From inside the container, hit any origin that returns a response without a Content-Length when compression is not requested. googleapis.com discovery is a stable example:

# (1) No Accept-Encoding: origin returns identity, chunked, no Content-Length
curl -sS -o /dev/null -D - https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest
HTTP/1.1 200 OK
Connection: close
Content-Type: application/json; charset=UTF-8
# no Content-Length, no Transfer-Encoding: body is close-delimited
# (2) Ask for gzip: origin returns a Content-Length, relayed length-delimited
curl -sS -o /dev/null -D - -H 'Accept-Encoding: gzip' https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest
HTTP/1.1 200 OK
Connection: close
Content-Encoding: gzip
Content-Length: 22848
Content-Type: application/json; charset=UTF-8

A rustls client (reqwest with rustls-tls) fails on case (1):

let client = reqwest::blocking::Client::builder().use_rustls_tls().build()?;
let _ = client
    .get("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest")
    .send()?
    .text()?;
// Err: reqwest::Error { kind: Decode, source: hyper::Error(Body,
//   Custom { kind: UnexpectedEof,
//     error: "peer closed connection without sending TLS close_notify" }) }

gws (Google Workspace CLI) sends no Accept-Encoding, so it hits case (1):

error[discovery]: error decoding response body: error reading a body from connection:
peer closed connection without sending TLS close_notify

What the worker sees vs what the container receives

Logging the response at the outbound handler's fetch() boundary vs what the container client receives:

client Accept-Encoding at worker fetch() boundary delivered to container
none / identity Transfer-Encoding: chunked, no Content-Length Connection: close, no Content-Length, no chunked (close-delimited)
gzip Content-Length: N Content-Length: N preserved (length-delimited)

The interceptor receives a chunked, self-terminating response and re-frames it as close-delimited when relaying to the container. Because it also closes TLS without close_notify, strict clients cannot tell a complete body from a truncated one.

Maybe Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions