cloudflare / cloudflare/workerd

`startTls({ expectedServerHostname })` is ignored on the production edge — no SNI is sent

Open
#6,903 1 comment 7 reactions 0 assignees View on GitHub
Dominant language
C++
Stars
8.7k
Forks
739
Avg merge
2d 20h
Merged PRs (30d)
174

Description

### Summary

`Socket.startTls(options)` accepts `TlsOptions.expectedServerHostname`, which should name
the host the TLS session is established with — this is what makes it possible to upgrade a
connection whose peer is not identified by the address passed to `connect()`.

On the production edge the option has no effect. A packet capture shows the resulting
ClientHello carries **no `server_name` extension at all**. The peer therefore answers with
its default certificate, the handshake is aborted with an alert, and the socket is reset.
The reader sees a silent EOF.

Local workerd (`wrangler dev`) honours the option, so this only reproduces against the real
edge (`wrangler dev --remote` or a deployed Worker).

### Reproduction

`wrangler.jsonc`:

```jsonc
{
"name": "starttls-sni-repro",
"main": "worker.js",
"compatibility_date": "2026-07-28"
}
```

`worker.js`:

```js
import { connect } from "cloudflare:sockets";

const HOSTNAME = "api.github.com";
const PORT = 443;

// GET /?ip= -> connect by IP, name the peer via startTls()
// GET /?control=1 -> connect by hostname (control)
export default {
async fetch(request) {
const params = new URL(request.url).searchParams;
const control = params.get("control") === "1";
const address = control ? HOSTNAME : params.get("ip");
if (!address) return new Response("pass ?ip=
or ?control=1\n", { status: 400 });

const socket = connect(
{ hostname: address, port: PORT },
{ secureTransport: "starttls", allowHalfOpen: false },
);
const secure = socket.startTls({ expectedServerHostname: HOSTNAME });

const writer = secure.writable.getWriter();
await writer.write(
new TextEncoder().encode(
`GET /zen HTTP/1.1\r\nHost: ${HOSTNAME}\r\n` +
`User-Agent: curl/8.7.1\r\nConnection: close\r\n\r\n`,
),
);

const reader = secure.readable.getReader();
const { value, done } = await reader.read();
return new Response(
done ? "EOF - no data received\n" : new TextDecoder().decode(value).split("\r\n")[0] + "\n",
);
},
};
```

Run it, resolving the address first (the A record rotates):

```bash
IP=$(dig +short api.github.com A | head -1)

npx wrangler dev # then: curl "localhost:8787/?ip=$IP"
npx wrangler dev --remote # then: curl "localhost:8787/?ip=$IP"
```

### Results

| | `?control=1` (connect by hostname) | `?ip=…` (connect by IP, named via `startTls`) |
| --- | --- | --- |
| `wrangler dev` (local workerd) | `HTTP/1.1 200 OK` | `HTTP/1.1 200 OK` |
| `wrangler dev --remote` (edge) | `HTTP/1.1 200 OK` | **`EOF - no data received`** |

Deterministic: 3/3 identical runs, and 0/10 in earlier runs of the same shape. The control
case shows `startTls()` itself works fine on the edge — only `expectedServerHostname` is
not applied.

### The ClientHello has no `server_name`

Capturing on a TCP endpoint under our control that the Worker reaches by IP, then calling
`startTls({ expectedServerHostname: "api.github.com" })`, the ClientHello that leaves
Cloudflare decodes as:

```
record 16 03 01 05 a7 handshake, len 1447
01 00 05 a3 ClientHello, len 1443
03 03 legacy_version TLS 1.2
<32-byte random> 20 <32-byte session id>
ciphers 00 1e 15 suites: 1301 1302 1303 c02b c02c cca9 c02f
c030 c013 c014 cca8 009c 009d 002f 0035
comp 01 00
ext 05 3c extensions, len 1340
0x0017 extended_master_secret
0xff01 renegotiation_info
0x000a supported_groups (11ec X25519MLKEM768, 6399, 001d, fe32, 0017, 0018, 0019)
0x000b ec_point_formats
0x0023 session_ticket
0x000d signature_algorithms
0x0033 key_share (1258 bytes)
0x002d psk_key_exchange_modes
0x002b supported_versions (0304, 0303)
```

There is no `0x0000` (`server_name`) extension. Because `connect()` was given an IP literal
and RFC 6066 forbids IP literals in SNI, SNI is omitted entirely rather than being filled
in from `expectedServerHostname`.

The connection-level trace of the same exchange:

```
edge -> peer len 1452 ClientHello
peer -> edge len 2848 ServerHello + Certificate + ...
edge -> peer ACK certificate received
edge -> peer len 30 ChangeCipherSpec (6) + encrypted alert (24)
edge -> peer RST
```

The server's full flight reaches Cloudflare intact; Cloudflare rejects it and resets. 30
bytes rules out the alternatives — a TLS 1.3 client `Finished` is around 60 bytes, and the
HTTP request above is ~130 bytes of plaintext.

For comparison, local workerd emits a ClientHello **with** SNI for the same code
(JA4 `t13d091000`, `d` = SNI present, 9 suites / 10 extensions) versus 15 suites /
9 extensions / no SNI on the edge. The two builds do not behave the same here.

### Not the same as #2712

#2712 reports `startTls()` failing after data has already been exchanged on the socket.
That is not what this is: a full SMTP `STARTTLS` sequence against
`smtp.gmail.com:587` — greeting, `EHLO`, `STARTTLS`, `startTls()`, `EHLO` again — succeeds
on the production edge. In that case `connect()` and the TLS peer are the same host, so no
`expectedServerHostname` is needed.

### Also ruled out

- Not flaky — 0/10 on the edge, 10/10 locally.
- Not `allowHalfOpen` — both values behave identically.
- Not stream-lock handling — acquiring and releasing the reader/writer without transferring
any data, then calling `startTls()`, works fine.
- Not the target port — 443 and 1012 fail the same way.
- Passing no options at all to `startTls()` produces the same failure, consistent with the
option never being read.

### Expected

`startTls({ expectedServerHostname })` should use that name for SNI and for certificate
verification, as local workerd does, so that a socket can be upgraded to TLS when the peer
is not identified by the `connect()` address.

### Environment

- wrangler 4.114.0
- `compatibility_date` `2026-07-28`, no compatibility flags
- Reproduced on `wrangler dev --remote`; local `wrangler dev` (workerd 1.20260722.1) is unaffected

Contributor guide

Open the contributing guide

Research direction

The reproduction is in worker.js and is configured by wrangler.jsonc; start by running the local and remote wrangler commands and comparing the ClientHello behavior. Trace the Socket.startTls entry point and the production-edge path for expectedServerHostname. Done means the remote connection sends SNI and verifies the expected hostname, allowing the IP-address connection to complete the HTTPS request.

Written by the indexing model from the issue text.

Assessment

Tech stack
cpp
Domain
networking, security
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.