Skip to content

https: built-in proxy support doesn't send SNI to an https:// proxy #66718

Open

Description

@st3phan

Version

v26.11.1 (also v22.21.1)

Platform

Darwin 25.5.0 arm64. Also seen on Linux in production.

Subsystem

https

What steps will reproduce the bug?

// HTTPS_PROXY=https://localhost:8443 NODE_USE_ENV_PROXY=1 node repro.mjs
import https from 'node:https';
import { createServer } from 'node:net';

// A fake proxy that reads the TLS ClientHello, which carries SNI as plain text
async function check(name, request) {
  const proxy = createServer((socket) => {
    socket.once('data', (clientHello) => {
      console.log(`${name}: proxy received SNI:`, clientHello.includes('localhost'));
      socket.destroy();
    });
  });
  await new Promise((resolve) => proxy.listen(8443, 'localhost', resolve));
  await request().catch(() => {});
  await new Promise((resolve) => proxy.close(resolve));
}

await check('https.get()', () => new Promise((_, reject) => https.get('https://example.com/').on('error', reject)));
await check('fetch()', () => fetch('https://example.com/'));
$ HTTPS_PROXY=https://localhost:8443 NODE_USE_ENV_PROXY=1 node repro.mjs

The same happens with new https.Agent({ proxyEnv: { HTTPS_PROXY: 'https://…' } }).

How often does it reproduce? Is there a required condition?

Always, when the proxy URL uses https:// and its host is a name rather than an IP address.

What is the expected behavior? Why is that the expected behavior?

https.get(): proxy received SNI: true
fetch(): proxy received SNI: true

The TLS connection to an https:// proxy should send the proxy's host name as SNI, just as https.request() does for any host and as fetch() does with the same proxy settings (undici fixed the same problem in nodejs/undici#2939). Servers that pick their certificate by SNI otherwise answer with a default certificate.

What do you see instead?

https.get(): proxy received SNI: false
fetch(): proxy received SNI: true

Proxies that pick their certificate by SNI then answer with their default certificate. When that certificate doesn't match the proxy's host name or has expired, every request fails, for example with CERT_HAS_EXPIRED, while fetch() works with the same proxy. Node still verifies the proxy's certificate against its host name, so security isn't affected, only the connection.

Additional information

ProxyConfig builds the options for the proxy connection from only host and port (lib/internal/http.js#L145-L151), and the https agent's createConnection() passes them to tls.connect() unchanged (lib/https.js#L399-L401, #L438). Unlike https.request(), tls.connect() sends no SNI unless servername is set. A possible fix is to set servername for https: proxies whose host isn't an IP address, as undici does.

Unrelated, but nearby: lib/https.js#L211 reads servername: reqOptions.servername || ipType ? undefined : reqOptions.host, which parses as (reqOptions.servername || ipType) ? undefined : reqOptions.host. It's harmless today, because the ...reqOptions spread that follows sets servername again, but it's probably not what was meant.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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