Skip to content

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

@juanibiapina

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
    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