Skip to content

http.request sends incorrect Host header for CONNECT requests (RFC 7230 and 9110 violation) #63945

@sohamsengupta17

Description

@sohamsengupta17

Version

v26.3.0

Platform

Darwin arm64

Subsystem

http

What steps will reproduce the bug?

Below is a minimal script to reproduce:

const http = require('http');
const net = require('net');

// Minimal TCP server to capture the raw bytes Node sends
const server = net.createServer((socket) => {
  socket.once('data', (data) => {
    console.log('Raw request received by proxy:\n');
    console.log(data.toString());
    socket.end();
  });
});

server.listen(9999, '127.0.0.1', () => {
  const req = http.request({
    host: '127.0.0.1',   // proxy host
    port: 9999,           // proxy port
    method: 'CONNECT',
    path: 'target.example.com:443',  // tunnel destination
  });

  req.on('error', () => {}); // ignore connection reset after server ends
  req.end();
});

Output:

Raw request received by proxy:

CONNECT target.example.com:443 HTTP/1.1
Host: 127.0.0.1:9999
Connection: keep-alive

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

Always — every http.request or https.request call with method: 'CONNECT' where no explicit Host header is provided.

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

The generated Host header should match the request-target (the tunnel destination), not the TCP connection destination (the proxy).

Per RFC 7231 §4.3.6 and the newer RFC 9110 §9.3.6, the canonical example for a CONNECT request is:

CONNECT server.example.com:80 HTTP/1.1
Host: server.example.com:80

RFC 7230 §5.3.3 states:

When making a CONNECT request to establish a tunnel through one or more proxies, a client MUST send only the target URI's authority component (excluding any userinfo and its "@" delimiter) as the request-target. For example,

CONNECT www.example.com:80 HTTP/1.1

And in §5.4 it states:

A client MUST send a Host header field in all HTTP/1.1 request messages. If the target URI includes an authority component, then a client MUST send a field-value for Host that is identical to that authority component, excluding any userinfo subcomponent and its "@" delimiter

Hence, since the authority (host:port of the tunnel destination) is the request-target for CONNECT, so Host must reflect that.

Expected output for the reproduction script:

CONNECT target.example.com:443 HTTP/1.1
Host: target.example.com:443
Connection: keep-alive

The ':port' suffix is optional in the Host header, so the below output should also be valid:

CONNECT target.example.com:443 HTTP/1.1
Host: target.example.com
Connection: keep-alive

What do you see instead?

CONNECT target.example.com:443 HTTP/1.1
Host: 127.0.0.1:9999
Connection: keep-alive

Host reflects the proxy's coordinates (host/port options = TCP connection destination) rather than the tunnel destination (path option = request-target authority).

Additional information

Root cause:
http.ClientRequest generates the Host header from the host and port options, which describe the TCP connection destination. For every HTTP method except CONNECT, the TCP destination and the request-target host are the same, so this works correctly. CONNECT is the only method where they diverge: the TCP connection goes to the proxy, but the Host header per the RFC must reflect the tunnel destination (the path option).

There is no special-case in ClientRequest to derive Host from path when method is CONNECT.

Possible fix:
When method === 'CONNECT' and no explicit Host header is provided, derive the generated Host value from the path option instead of host/port.

Workaround:
Explicitly passing the Host header achieves the expected behaviour:

http.request({
  host: 'proxy.example.com',
  port: 3128,
  method: 'CONNECT',
  path: 'target.example.com:443',
  headers: {
    'Host': 'target.example.com:443'
  }
})

Behaviour of other clients:
We tested curl, Java and Go, all of which send the RFC-correct Host:

curl 8.7.1:

CONNECT target.example.com:443 HTTP/1.1
Host: target.example.com:443
User-Agent: curl/8.7.1
Proxy-Connection: Keep-Alive

Go net/http:

CONNECT target.example.com:443 HTTP/1.1
Host: target.example.com:443
User-Agent: Go-http-client/1.1

Java HttpURLConnection (OpenJDK 24):

CONNECT target.example.com:443 HTTP/1.1
User-Agent: Java/24
Host: target.example.com
Accept: */*
Proxy-Connection: keep-alive

Note on impact:
We have encountered a proxy (which is not managed by us hence unfortunately do not have more config details) to which our CONNECT request fails due to this issue. Most proxy configurations might ignore the Host header on CONNECT and act solely on the request-line authority, and this does not cause failures in those cases.
However it is a clear conformance violation, and stricter or policy-enforcing proxies as mentioned above are within their rights to reject the request and flag the mismatch.

RFC references:

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