CONNECT asks a proxy to establish a tunnel; it does not make the proxy disappear from the data path. After admission, the proxy still forwards bytes in both directions and owns sockets, buffers, and failure handling. CONNECT itself provides no encryption: HTTPS adds a separately verified TLS session through that tunnel.
Switch protocols at the right boundary
An HTTP/1.1 CONNECT target is an authority such as server.example:443, not an ordinary path or a full HTTPS URL. The port is explicit. A successful 2xx response switches to tunnel mode immediately after its header section. A non-success response leaves the tunnel unestablished.
A successful CONNECT response must not include Content-Length or Transfer-Encoding. Subsequent tunnel bytes are not a length-delimited HTTP response body. This distinction matters when writing a relay: bytes already buffered by the HTTP parser belong to the tunnel and must not be discarded.
The client can then negotiate TLS with the destination through the relay. A proxy’s 200 only confirms tunnel admission; it does not validate the destination certificate or prove that an application request succeeded. A client must still check trust, hostname, and the application response. TLS interception is a different deployment, requiring the client to trust an intermediary that terminates TLS.
Opaque content still consumes resources
A blind relay need not interpret encrypted application content, but it still observes connection targets, byte volumes, timing, and closure. It can enforce connection budgets, timeouts, traffic limits, and revocation of admission. It does not lose all operational control merely because it cannot read the HTTP messages inside TLS.
Authenticate callers and constrain destinations in a real forward proxy. Arbitrary host and port access can expose internal services or enable misuse of unrelated protocols. Hostname policy also needs address-resolution policy; validating a name is not equivalent to validating the IP address ultimately dialed. The local exercise avoids that issue by permitting only one already-known loopback address and port.
When it breaks
A relay can return 200 before an upstream connection actually exists, discard parser-buffered bytes, or close the opposite direction before pending data is forwarded. These failures can appear as mysterious TLS errors even when certificate configuration is correct. Distinguish admission failure, tunnel transport failure, TLS identity failure, and origin HTTP failure.
Hijacking a Go HTTP/1.1 connection transfers lifecycle ownership to the handler. The normal HTTP server no longer manages that connection, and its request context is not a substitute for tunnel cleanup. Normal server shutdown does not close hijacked connections; production shutdown needs to track and drain or close them explicitly. Handle both copy directions and their deadlines. A fixed absolute deadline is a total lifetime cap, not an idle timeout that refreshes with traffic.
Check it quickly
Save this as connect_probe.go and run go run connect_probe.go with Go 1.23 or newer. The fixture creates a loopback HTTP/1.1 proxy and a TLS 1.3 origin. Only the origin’s exact authority is permitted. The client trusts its test certificate without disabling verification or changing the system trust store.
package main
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/http/httptest"
"net/url"
"runtime"
"sync/atomic"
"time"
)
func must(err error) {
if err != nil {
panic(err)
}
}
func check(ok bool, message string) {
if !ok {
panic(message)
}
}
func main() {
var handled, admitted, denied, connectStatus atomic.Int64
origin := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handled.Add(1)
check(r.TLS != nil && r.TLS.Version == tls.VersionTLS13, "origin requires TLS 1.3")
fmt.Fprint(w, "through the tunnel")
}))
origin.Config.ErrorLog = log.New(io.Discard, "", 0) // Expected wrong-name handshake failure.
origin.TLS = &tls.Config{MinVersion: tls.VersionTLS13, MaxVersion: tls.VersionTLS13}
origin.StartTLS()
defer origin.Close()
target, err := url.Parse(origin.URL)
must(err)
relayed := make(chan [2]int64, 3)
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodConnect {
http.Error(w, "CONNECT required", http.StatusMethodNotAllowed)
return
}
if r.Host != target.Host || r.URL.Host != target.Host {
denied.Add(1)
http.Error(w, "target denied", http.StatusForbidden)
return
}
upstream, err := net.DialTimeout("tcp", target.Host, time.Second)
must(err)
defer upstream.Close()
downstream, buffered, err := w.(http.Hijacker).Hijack()
must(err)
defer downstream.Close()
admitted.Add(1)
deadline := time.Now().Add(3 * time.Second)
must(upstream.SetDeadline(deadline))
must(downstream.SetDeadline(deadline))
_, err = buffered.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n")
must(err)
must(buffered.Flush())
upDone := make(chan int64, 1)
go func() {
count, _ := io.Copy(upstream, buffered) // Preserve already-buffered tunnel bytes.
_ = upstream.(*net.TCPConn).CloseWrite()
upDone <- count
}()
count, _ := io.Copy(downstream, upstream)
_ = downstream.(*net.TCPConn).CloseWrite()
relayed <- [2]int64{<-upDone, count}
}))
defer proxy.Close()
proxyURL, err := url.Parse(proxy.URL)
must(err)
config := origin.Client().Transport.(*http.Transport).TLSClientConfig.Clone()
check(!config.InsecureSkipVerify, "certificate verification must stay enabled")
transport := &http.Transport{
Proxy: http.ProxyURL(proxyURL), TLSClientConfig: config, DisableKeepAlives: true,
OnProxyConnectResponse: func(_ context.Context, _ *url.URL, _ *http.Request, response *http.Response) error {
connectStatus.Store(int64(response.StatusCode))
if response.StatusCode == 200 {
check(response.Header.Get("Content-Length") == "" && response.Header.Get("Transfer-Encoding") == "", "successful CONNECT must not frame a body")
}
return nil
},
}
defer transport.CloseIdleConnections()
get := func(tr *http.Transport, address string) (*http.Response, error) {
return (&http.Client{Transport: tr, Timeout: 2 * time.Second}).Get(address)
}
success := func(label string) {
response, err := get(transport, origin.URL)
must(err)
body, readError := io.ReadAll(response.Body)
closeError := response.Body.Close()
must(readError)
must(closeError)
check(response.StatusCode == 200 && string(body) == "through the tunnel", "wrong origin response")
check(connectStatus.Load() == 200 && len(response.TLS.VerifiedChains) > 0, "CONNECT and verified TLS required")
fmt.Println(label, "CONNECT=200; verified origin response")
}
success("allowed")
response, err := get(transport, "https://denied.invalid:443/")
check(err != nil && response == nil && connectStatus.Load() == 403, "proxy must reject the target")
check(denied.Load() == 1 && admitted.Load() == 1 && handled.Load() == 1, "denial must not open an upstream tunnel")
fmt.Println("denied CONNECT=403; no upstream admission")
wrong := transport.Clone()
wrong.TLSClientConfig = config.Clone()
wrong.TLSClientConfig.ServerName = "wrong.invalid"
defer wrong.CloseIdleConnections()
response, err = get(wrong, origin.URL)
var hostnameError x509.HostnameError
check(response == nil && errors.As(err, &hostnameError) && connectStatus.Load() == 200, "client must verify the tunneled TLS identity")
check(handled.Load() == 1, "wrong-name TLS must not reach the HTTP handler")
fmt.Println("wrong name CONNECT=200; TLS rejected before origin HTTP")
success("recovery")
check(handled.Load() == 2 && admitted.Load() == 3, "wrong final origin/tunnel counts")
for index := 0; index < 3; index++ {
select {
case counts := <-relayed:
check(counts[0] > 0 && counts[1] > 0, "proxy must relay bytes in both directions")
case <-time.After(4 * time.Second):
panic("relay did not finish")
}
}
fmt.Println("three completed relays; two successful origin HTTP requests")
fmt.Println(runtime.Version())
}
The allowed request records CONNECT 200 and receives the complete origin body. The denied.invalid target records CONNECT 403 without another upstream admission. The name is never resolved by this fixture. A third attempt gets CONNECT 200 but fails certificate hostname verification; the origin’s HTTP handler count remains unchanged. A final correctly configured request succeeds.
Three admitted relays forward bytes in both directions; only two origin HTTP requests complete. DisableKeepAlives prevents unrelated connection reuse from concealing a new CONNECT exchange. The proxy response callback checks actual admission status and the successful response’s framing fields. Copy completion is awaited, and each hijacked socket is explicitly closed.
All assertions passed on macOS with Go 1.27.0. Requests have two-second timeouts, dialing has a one-second timeout, and tunnel sockets have a three-second absolute deadline. This is an isolated protocol exercise, not a deployable public proxy. Authentication, DNS policy, interception, buffered early-data injection, traffic shaping, sustained load, and HTTP/2 or HTTP/3 CONNECT mappings are not tested.
Further reading
Sources
- RFC 9110: HTTP semantics, CONNECT admission, authority targets, relay ownership, and destination restrictions, checked 2026-09-09.
- RFC 9112: HTTP/1.1, message framing and the CONNECT tunnel boundary, checked 2026-09-09.
- Go net/http, proxy response observation and ownership of hijacked connections, checked 2026-09-09.
- Go net, connection deadlines and TCP half-close behavior, checked 2026-09-09.
- Go crypto/tls, certificate validation and TLS connection state, checked 2026-09-09.