Skip to content

Commit 2f6faa2

Browse files
committed
feat(forwardproxy): reversible TLS bridge on CONNECT path
Signed-off-by: Hai Huang <huang195@gmail.com>
1 parent 85ed852 commit 2f6faa2

2 files changed

Lines changed: 205 additions & 0 deletions

File tree

authbridge/authlib/listener/forwardproxy/server.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package forwardproxy
55

66
import (
7+
"bufio"
78
"bytes"
89
"context"
910
cryptotls "crypto/tls"
@@ -867,6 +868,30 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
867868
s.recordTunnelOpened(pctx)
868869
}
869870

871+
if s.TLSBridge != nil {
872+
pc := &peekedConn{Conn: clientConn, r: bufio.NewReaderSize(clientConn, sniffBufSize)}
873+
clientConn = pc // replay peeked bytes into whichever path runs
874+
first, _ := pc.Peek(5)
875+
authority := r.Host // CONNECT target is already host:port
876+
key := hostOnly(r.Host)
877+
if !s.TLSBridge.Skip.Contains(key) {
878+
// ip is the hostname for a name CONNECT target (ParseIP→nil ⇒ never
879+
// matched as in-cluster; the transparent path keys on the dialed IP).
880+
if v, _ := s.TLSBridge.Decision.Classify(key, key, portOf(r.Host), first); v == tlsbridge.Terminate {
881+
_ = upstream.Close() // bridgeServe dials its own verified upstream
882+
if s.bridgeServe(clientConn, authority, key) {
883+
return
884+
}
885+
// fell open → re-dial for the tunnel
886+
if up2, derr := net.DialTimeout("tcp", r.Host, connectDialTimeout); derr == nil {
887+
tunnel(clientConn, up2)
888+
_ = up2.Close()
889+
}
890+
return
891+
}
892+
}
893+
}
894+
870895
// Bidirectional copy until either side closes.
871896
tunnel(clientConn, upstream)
872897
}

authbridge/authlib/listener/forwardproxy/tlsbridge_integration_test.go

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,3 +222,183 @@ func TestTransparentBridge(t *testing.T) {
222222
t.Fatalf("HandleTransparentConn did not return after conns closed")
223223
}
224224
}
225+
226+
// TestConnectBridge drives the full CONNECT → 200 → agent-TLS → decrypt →
227+
// pipeline → re-originate loop through the PUBLIC forward-proxy HTTP handler
228+
// (so the real net/http Hijack path runs). An httptest TLS origin stands in
229+
// for the upstream. The agent dials the proxy, issues CONNECT, reads the 200,
230+
// then speaks TLS over the SAME raw conn trusting the bridge's ephemeral CA;
231+
// the proxy forges a leaf, terminates, runs the pipeline (probe records the
232+
// plaintext), and re-originates to the real origin. Body must arrive intact.
233+
func TestConnectBridge(t *testing.T) {
234+
// 1) TLS origin on a random port. CONNECT doesn't go through shouldSniff,
235+
// so any port works here.
236+
var gotOriginPath string
237+
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
238+
gotOriginPath = r.URL.Path
239+
if r.URL.Path == "/secret" {
240+
_, _ = w.Write([]byte("OK-SECRET"))
241+
return
242+
}
243+
w.WriteHeader(http.StatusNotFound)
244+
})
245+
origin := httptest.NewTLSServer(handler)
246+
defer origin.Close()
247+
248+
originCAPEM := pem.EncodeToMemory(&pem.Block{
249+
Type: "CERTIFICATE",
250+
Bytes: origin.Certificate().Raw,
251+
})
252+
253+
originURL, err := url.Parse(origin.URL)
254+
if err != nil {
255+
t.Fatalf("parse origin URL: %v", err)
256+
}
257+
originHostPort := originURL.Host // "127.0.0.1:port"
258+
259+
// 2) Build the bridge Engine. ScopeAll so the loopback origin isn't
260+
// treated as in-cluster and skipped. Ports must include the origin's
261+
// random port (the CONNECT classify keys on portOf(r.Host)).
262+
src, err := tlsbridge.NewEphemeralSource()
263+
if err != nil {
264+
t.Fatalf("NewEphemeralSource: %v", err)
265+
}
266+
minter := tlsbridge.NewMinter(src, tlsbridge.MinterOpts{})
267+
up, err := tlsbridge.NewUpstreamClient(originCAPEM)
268+
if err != nil {
269+
t.Fatalf("NewUpstreamClient: %v", err)
270+
}
271+
engine := &tlsbridge.Engine{
272+
Decision: tlsbridge.NewDecision(tlsbridge.DecisionOpts{
273+
Scope: tlsbridge.ScopeAll,
274+
Ports: map[int]bool{portOf(originHostPort): true},
275+
}),
276+
Term: tlsbridge.NewTerminator(minter),
277+
Skip: tlsbridge.NewSkipSet(),
278+
Upstream: up,
279+
CAPEM: src.CACertPEM(),
280+
}
281+
282+
// 3) Server wired with a real OutboundPipeline carrying the recording probe.
283+
probe := &bridgeProbePlugin{}
284+
p, err := plugintesting.BuildPipeline([]pipeline.Plugin{probe})
285+
if err != nil {
286+
t.Fatalf("BuildPipeline: %v", err)
287+
}
288+
srv := &Server{
289+
OutboundPipeline: pipeline.NewHolder(p),
290+
Client: http.DefaultClient,
291+
TLSBridge: engine,
292+
}
293+
294+
// 4) Stand up the public forward-proxy HTTP handler so Hijack works.
295+
proxy := httptest.NewServer(srv.Handler())
296+
defer proxy.Close()
297+
proxyURL, err := url.Parse(proxy.URL)
298+
if err != nil {
299+
t.Fatalf("parse proxy URL: %v", err)
300+
}
301+
302+
// 5) Raw-dial the proxy and issue CONNECT to the origin host:port.
303+
rawConn, err := net.Dial("tcp", proxyURL.Host)
304+
if err != nil {
305+
t.Fatalf("dial proxy: %v", err)
306+
}
307+
defer func() { _ = rawConn.Close() }()
308+
_ = rawConn.SetDeadline(time.Now().Add(10 * time.Second))
309+
310+
connectReq, err := http.NewRequest(http.MethodConnect, "//"+originHostPort, nil)
311+
if err != nil {
312+
t.Fatalf("new CONNECT request: %v", err)
313+
}
314+
connectReq.Host = originHostPort
315+
connectReq.URL.Host = originHostPort
316+
if err := connectReq.Write(rawConn); err != nil {
317+
t.Fatalf("write CONNECT: %v", err)
318+
}
319+
320+
// Read the 200 Connection Established. http.ReadResponse against the
321+
// CONNECT request consumes exactly the status line + headers (no body),
322+
// leaving the raw conn positioned at the first post-200 byte — which is
323+
// where the agent's ClientHello begins.
324+
br := bufio.NewReader(rawConn)
325+
connectResp, err := http.ReadResponse(br, connectReq)
326+
if err != nil {
327+
t.Fatalf("read CONNECT response: %v", err)
328+
}
329+
if connectResp.StatusCode != http.StatusOK {
330+
t.Fatalf("CONNECT status = %d, want 200", connectResp.StatusCode)
331+
}
332+
_ = connectResp.Body.Close()
333+
334+
// 6) Agent-side TLS over the SAME raw conn (wrapped so any bytes ReadResponse
335+
// buffered past the 200 are replayed — there should be none, but be safe).
336+
pool := x509.NewCertPool()
337+
if !pool.AppendCertsFromPEM(engine.CAPEM) {
338+
t.Fatalf("failed to append bridge CA PEM to pool")
339+
}
340+
host := hostOnly(originHostPort) // "127.0.0.1"
341+
tlsTransport := &bufferedConn{Conn: rawConn, r: br}
342+
tconn := tls.Client(tlsTransport, &tls.Config{
343+
ServerName: host,
344+
RootCAs: pool,
345+
NextProtos: []string{"http/1.1"},
346+
})
347+
_ = tconn.SetDeadline(time.Now().Add(10 * time.Second))
348+
if err := tconn.Handshake(); err != nil {
349+
t.Fatalf("agent-side TLS handshake through CONNECT bridge failed: %v", err)
350+
}
351+
352+
// 7) GET /secret over the bridged TLS conn. Same goroutine-write +
353+
// http.ReadResponse split as TestTransparentBridge: the server makes a
354+
// blocking upstream round-trip between reading the request and writing
355+
// the response, so a single-goroutine RoundTrip can wedge.
356+
req, err := http.NewRequest(http.MethodGet, "https://"+host+"/secret", nil)
357+
if err != nil {
358+
t.Fatalf("new request: %v", err)
359+
}
360+
writeErr := make(chan error, 1)
361+
go func() { writeErr <- req.Write(tconn) }()
362+
363+
resp, err := http.ReadResponse(bufio.NewReader(tconn), req)
364+
if err != nil {
365+
t.Fatalf("read response over bridged TLS conn: %v", err)
366+
}
367+
defer resp.Body.Close()
368+
body, err := io.ReadAll(resp.Body)
369+
if err != nil {
370+
t.Fatalf("read response body: %v", err)
371+
}
372+
if werr := <-writeErr; werr != nil {
373+
t.Fatalf("write request over bridged TLS conn: %v", werr)
374+
}
375+
376+
// 8) Assertions.
377+
if probe.calls == 0 {
378+
t.Fatalf("probe plugin never ran — pipeline did not see decrypted request (CONNECT bridge branch missing?)")
379+
}
380+
if probe.gotPath != "/secret" {
381+
t.Errorf("probe recorded path = %q, want /secret", probe.gotPath)
382+
}
383+
if probe.gotMethod != http.MethodGet {
384+
t.Errorf("probe recorded method = %q, want GET", probe.gotMethod)
385+
}
386+
if gotOriginPath != "/secret" {
387+
t.Errorf("origin received path = %q, want /secret", gotOriginPath)
388+
}
389+
if got := strings.TrimSpace(string(body)); got != "OK-SECRET" {
390+
t.Errorf("response body = %q, want OK-SECRET", got)
391+
}
392+
}
393+
394+
// bufferedConn wraps a net.Conn whose leading bytes were partly drained into a
395+
// bufio.Reader (e.g. by http.ReadResponse on the CONNECT 200), so a subsequent
396+
// reader (the agent's tls.Client) replays those buffered bytes before reading
397+
// from the wire. Mirrors peekedConn's Read-replays-buffered semantics for the
398+
// client side of the test harness.
399+
type bufferedConn struct {
400+
net.Conn
401+
r *bufio.Reader
402+
}
403+
404+
func (c *bufferedConn) Read(p []byte) (int, error) { return c.r.Read(p) }

0 commit comments

Comments
 (0)