cloudflare / cloudflare/containers
Egress interceptor close-delimits no-`Content-Length` responses (drops chunked framing, no TLS `close_notify`) → breaks strict TLS (rustls) clients
- Dominant language
- TypeScript
- Stars
- 270
- Forks
- 42
- Avg merge
- 1d 5h
- Merged PRs (30d)
- 4
Description
## 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:
```ts
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:
```sh
# (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
```
```sh
# (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):
```rust
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
- #53 (proxying a chunked `ReadableStream` through the container proxy)
- #98 / #116 / #137 (streaming-body / no-`Content-Length` egress failures)
- rustls strictness on EOF without `close_notify`: https://docs.rs/rustls/latest/rustls/manual/_03_howto/index.html#unexpected-eof
Contributor guide
Assessment
This issue has not been assessed yet.