Skip to content

CONNECT tunneling

The CONNECT method (RFC 9110 §9.3.6) asks the server to open a TCP connection to another host and relay bytes in both directions. It is how a client reaches an HTTPS origin through a forward proxy. aeronet supports it over HTTP/1.1, where the tunnel takes over the whole connection, and over HTTP/2, where each tunnel is one stream.

CONNECT is disabled by default. A server that relays connections to arbitrary hosts is an open proxy, so the allowed targets must be listed explicitly.

Enable CONNECT

HttpServerConfig::withConnectAllowlist() sets the hosts a client may connect to:

#include <array>
#include <string_view>

static constexpr std::array<std::string_view, 2> kAllowedTargets{"api.internal.example", "10.0.0.12"};

HttpServerConfig config;
config.withPort(3128).withConnectAllowlist(kAllowedTargets.begin(), kAllowedTargets.end());
Allowlist Effect
Empty (default) CONNECT is refused with 403 Forbidden.
Host names or IP addresses The target host must match an entry exactly, case-insensitively. A listed host can be reached on any port. List IPv6 addresses without brackets (::1).
"*" Every host and port is allowed.

The allowlist is also available in JSON and YAML configuration files as connectAllowlist.

Danger

"*" lets clients reach loopback, private network, link-local, and cloud metadata addresses (such as 169.254.169.254) from the server's network position. Use it only when unrestricted proxying is intended and access to the listener is controlled by other means.

HTTP/1.1 tunnels

The request target of a CONNECT request is in authority form, host:port, with an IPv6 address enclosed in brackets ([2001:db8::7]:443):

CONNECT api.internal.example:443 HTTP/1.1
Host: api.internal.example:443

The server then:

  1. validates the target: the port must be numeric, between 1 and 65535 (RFC 3986 port = *DIGIT). A missing port, a service name such as host:https, or an out-of-range value is answered 400 Bad Request, before any name resolution;
  2. checks the allowlist, and answers 403 Forbidden for a target that is not allowed;
  3. resolves the host name, see Name resolution, and starts a non-blocking TCP connection. A resolution failure, or a connection that fails, is answered 502 Bad Gateway;
  4. answers 200 OK and relays bytes between the client and the target until either side closes. As required for a successful CONNECT response (RFC 9110 §9.3.6), the 200 has neither content nor Content-Length: the tunneled bytes directly follow its head.

From that point, the connection no longer carries HTTP: the server forwards bytes without parsing them. Bytes the client sends right after the CONNECT head, even in the same packet, or while the 200 response is being written, are forwarded too. Each side keeps its own buffer for the bytes forwarded to it, separate from HTTP response buffering. When one side closes or fails, the server closes the other side. Tunnels are not subject to the keep-alive idle timeout.

Backpressure

A tunnel never buffers more than it can write. When one side reads more slowly than the other sends (a client that stops reading a download, or a target slower than an upload), the bytes waiting for that side are bounded by maxOutboundBufferBytes (4 MiB by default): once they reach it, the server stops reading the sending side, until the slow side has received them. The bytes then wait in the kernel socket buffers, and TCP flow control slows the sender down. Over HTTP/2, the same applies in both directions, see CONNECT over HTTP/2.

Name resolution

Resolving a host name with getaddrinfo can take seconds when the DNS server is slow or unreachable, and it cannot be interrupted. It thus never runs on the event-loop thread: each server thread resolves CONNECT targets on background threads, started on demand (at most 4 resolve concurrently, the next ones wait), while the event loop keeps serving its other connections.

  • An IP address (10.0.0.12, [::1]) needs no resolution: the server connects to it right away.
  • While the host name of an HTTP/1.1 CONNECT request is resolved, the server reads nothing more from its connection: the request is answered (200 or 502) once getaddrinfo returns. The bytes the client sent meanwhile are forwarded once the tunnel is established.
  • Over HTTP/2, the stream waits for the resolution the same way, and the DATA frames received meanwhile are forwarded once the tunnel is established. The server grants no flow-control credit to the stream until then, so they are bounded by its initial window (http2.initialWindowSize, 1 MiB by default). The other streams of the connection are not delayed.
  • The wait is bounded by keepAliveTimeout (5 s by default): a request whose target is not resolved by then is answered 504 Gateway Timeout, and its resolution is dropped (a resolution already running completes on its thread, and its result is ignored).
  • At most 256 CONNECT requests per server thread wait for their resolution. The next ones are answered 503 Service Unavailable right away, until some resolutions complete or time out, so that a slow or unreachable DNS server cannot make clients hold connections indefinitely.
  • When the client closes its connection, or resets the stream, before the resolution completes, its result is discarded. Stopping the server does not wait for resolutions in progress either.

The resolver of the operating system applies its own timeouts and retries (options timeout and attempts in /etc/resolv.conf on Linux), and its own cache, if any.

CONNECT over HTTP/2

Over HTTP/2 (RFC 9113 §8.5), a tunnel occupies a single stream, so several tunnels and ordinary requests can share one connection.

  • The request carries :method CONNECT and :authority host:port, and neither :scheme nor :path. The port must be numeric and in range (400 otherwise), and the allowlist applies (403). Host names are resolved in the background, and a target that cannot be resolved or reached is answered 502.
  • On success, the server answers 200 without ending the stream. DATA frames on the stream then carry the tunneled bytes in both directions.
  • When the connection to the target fails after the request was accepted, the stream is reset with CONNECT_ERROR.
  • Flow control applies to the tunneled bytes. Toward the target, the server stops granting credit to the stream (WINDOW_UPDATE) while bytes wait for the target to be writable: what it holds for a slow target is bounded by the stream window. Toward the client, it sends within the windows granted by the client, and stops reading the target while the output of the connection reaches maxOutboundBufferBytes, in case the client grants credit without reading.
  • END_STREAM or RST_STREAM from either side tears the tunnel down, and closing the HTTP/2 connection closes all its tunnels.

Extended CONNECT (RFC 8441, the :protocol pseudo-header used for WebSocket over HTTP/2) is not supported.

Limitations

  • The allowlist matches hosts, not ports. Restrict ports with a firewall when needed.
  • The server does not authenticate CONNECT requests (no Proxy-Authorization support), and HTTP/1.1 CONNECT requests are handled before request middleware runs. Restrict who can reach a listener that allows CONNECT.

Tests