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
Summary
With
interceptHttps = true, the Containers egress interceptor MITM-terminates the container's outbound TLS. When the upstream response has noContent-Length(for example an uncompressed response the origin streams viaTransfer-Encoding: chunkedor HTTP/2), the interceptor delivers it to the in-container client as a connection-close-delimited HTTP/1.1 response (Connection: close, noContent-Length, no chunked terminator) and then closes the TLS connection without aclose_notifyalert.Lenient clients (curl, Go
net/http, Node undici) tolerate this and work. rustls clients reject it withUnexpectedEof: 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/containers0.3.5wrangler4.97.0interceptHttps = true, catch-alloutboundhandler forwarding viafetchReproduced on a deployed container with a trivial
(req) => fetch(req)pass-through outbound handler. The worker-side log shows aTransfer-Encoding: chunkedresponse; the in-container client receives it withConnection: close, noContent-Length, and no chunked terminator.Minimal repro
Container class with HTTPS interception and a pass-through outbound handler:
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 aContent-Lengthwhen compression is not requested.googleapis.comdiscovery 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/restA rustls client (reqwest with
rustls-tls) fails on case (1):gws(Google Workspace CLI) sends noAccept-Encoding, so it hits case (1):What the worker sees vs what the container receives
Logging the response at the
outboundhandler'sfetch()boundary vs what the container client receives:Accept-Encodingfetch()boundaryidentityTransfer-Encoding: chunked, noContent-LengthConnection: close, noContent-Length, no chunked (close-delimited)gzipContent-Length: NContent-Length: Npreserved (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
ReadableStreamthrough the container proxy)Content-Lengthegress failures)close_notify: https://docs.rs/rustls/latest/rustls/manual/_03_howto/index.html#unexpected-eof