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):
The server then:
- validates the target: the port must be numeric, between 1 and 65535 (RFC 3986
port = *DIGIT). A missing port, a service name such ashost:https, or an out-of-range value is answered400 Bad Request, before any name resolution; - checks the allowlist, and answers
403 Forbiddenfor a target that is not allowed; - 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; - answers
200 OKand relays bytes between the client and the target until either side closes. As required for a successful CONNECT response (RFC 9110 §9.3.6), the200has neither content norContent-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 (
200or502) oncegetaddrinforeturns. 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 answered504 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 Unavailableright 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 CONNECTand:authority host:port, and neither:schemenor:path. The port must be numeric and in range (400otherwise), and the allowlist applies (403). Host names are resolved in the background, and a target that cannot be resolved or reached is answered502. - On success, the server answers
200without 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 reachesmaxOutboundBufferBytes, in case the client grants credit without reading. END_STREAMorRST_STREAMfrom 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-Authorizationsupport), and HTTP/1.1 CONNECT requests are handled before request middleware runs. Restrict who can reach a listener that allows CONNECT.
Tests¶
- HTTP/1.1 tunnels, target validation, allowlist, background resolution and its limits (
504,503), DNS failure, backpressure in both directions, and cleanup: tests/http-connect_test.cpp. - HTTP/2 tunnels, flow control and backpressure, resolution limits: tests/http2-connect_test.cpp, and streams waiting for their resolution or for a slow target: http2-protocol-handler_test.cpp. Stream window hold: http2-connection_test.cpp.
- Target parsing: connect-target_test.cpp. Background resolution: tunnel-resolver_test.cpp.