Version
v26.3.0
Platform
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:
Version
v26.3.0
Platform
Subsystem
http
What steps will reproduce the bug?
Below is a minimal script to reproduce:
Output:
How often does it reproduce? Is there a required condition?
Always — every
http.requestorhttps.requestcall withmethod: 'CONNECT'where no explicitHostheader is provided.What is the expected behavior? Why is that the expected behavior?
The generated
Hostheader 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:
RFC 7230 §5.3.3 states:
And in §5.4 it states:
Hence, since the authority (
host:portof the tunnel destination) is the request-target for CONNECT, soHostmust reflect that.Expected output for the reproduction script:
The ':port' suffix is optional in the Host header, so the below output should also be valid:
What do you see instead?
Hostreflects the proxy's coordinates (host/portoptions = TCP connection destination) rather than the tunnel destination (pathoption = request-target authority).Additional information
Root cause:
http.ClientRequestgenerates theHostheader from thehostandportoptions, 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 theHostheader per the RFC must reflect the tunnel destination (thepathoption).There is no special-case in
ClientRequestto deriveHostfrompathwhenmethodisCONNECT.Possible fix:
When
method === 'CONNECT'and no explicitHostheader is provided, derive the generatedHostvalue from thepathoption instead ofhost/port.Workaround:
Explicitly passing the
Hostheader achieves the expected behaviour:Behaviour of other clients:
We tested curl, Java and Go, all of which send the RFC-correct Host:
curl 8.7.1:
Go net/http:
Java HttpURLConnection (OpenJDK 24):
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
Hostheader 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: