Skip to content

Commit 66f82e9

Browse files
committed
feat(transparent): Recover destination hostname (SNI + HTTP Host) for policy parity
Captured (iptables-REDIRECTed) traffic previously gated on the SO_ORIGINAL_DST IP:port, while the explicit-proxy CONNECT path gates on the hostname — so host/domain egress policy did not apply to captured bypass traffic the same way. Add forwardproxy/sniff.go: peek the connection's first bytes (without consuming them — replayed into the tunnel via a bufio-backed peekedConn) and recover the destination hostname: - HTTPS: parse the TLS ClientHello SNI, leaning on crypto/tls's own parser via a throwaway GetConfigForClient handshake (no hand-rolled ClientHello parsing). - HTTP: parse the Host header from the request preamble. Fall back to the IP when neither is recoverable. HandleTransparentConn uses the recovered name as pctx.Host; the dial target ALWAYS stays the SO_ORIGINAL_DST IP (never re-resolve the name). Sniffing is gated to HTTP/TLS-typical ports (80/443/8080/8443) so non-HTTP, often server-first protocols (SSH, SMTP, ...) are not delayed by the peek; a read deadline bounds it regardless. Tests: real ClientHello (via crypto/tls) yields the SNI; a plaintext request yields the Host header (port stripped); opaque bytes yield no name; all three prove the peeked bytes are replayed verbatim. End-to-end (container) verified: an agent dialing the IP while sending Host: api.example.test has the listener recover api.example.test, and capture-vs-drop still holds. Trust caveat (documented): for captured traffic the agent controls both the name and the IP, so name-based policy is reliable against a cooperative or misconfigured agent but is not a hard control against a hostile one — only the IP is ground truth. The always-on operator PR must decide that threat model. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
1 parent 6646b57 commit 66f82e9

3 files changed

Lines changed: 352 additions & 15 deletions

File tree

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
package forwardproxy
2+
3+
import (
4+
"bufio"
5+
"bytes"
6+
cryptotls "crypto/tls"
7+
"errors"
8+
"io"
9+
"net"
10+
"net/http"
11+
"time"
12+
)
13+
14+
// Captured (iptables-REDIRECTed) connections carry no CONNECT line, so the
15+
// destination hostname must be recovered from the connection's own first bytes:
16+
// the TLS ClientHello SNI for HTTPS, or the HTTP Host header for plaintext HTTP.
17+
// This gives policy parity with the explicit-proxy path (which reads r.Host).
18+
// The recovered name is used ONLY as the policy key (pctx.Host); the dial target
19+
// stays the SO_ORIGINAL_DST IP. See HandleTransparentConn.
20+
21+
const (
22+
// sniffBufSize bounds how much of the leading bytes we buffer to find the
23+
// SNI / Host header. Real ClientHellos and request header blocks fit well
24+
// within this; anything larger falls back to the IP.
25+
sniffBufSize = 8192
26+
// sniffTimeout bounds the peek so a client that connects but sends nothing
27+
// (or a server-first protocol) can't pin a goroutine. Only relevant on the
28+
// sniffed ports, where the client speaks first, so it is rarely hit.
29+
sniffTimeout = 5 * time.Second
30+
)
31+
32+
// errSniffDone aborts the throwaway TLS handshake once we have the SNI.
33+
var errSniffDone = errors.New("forwardproxy: sni sniff complete")
34+
35+
// shouldSniff reports whether dst's port is one where the client speaks first
36+
// with an HTTP/TLS preamble we can parse. Gating on these ports avoids adding
37+
// peek latency to non-HTTP, often server-first protocols (SSH:22, SMTP:25, ...)
38+
// that we would only ever blind-tunnel anyway.
39+
func shouldSniff(dst string) bool {
40+
_, port, err := net.SplitHostPort(dst)
41+
if err != nil {
42+
return false
43+
}
44+
switch port {
45+
case "80", "443", "8080", "8443":
46+
return true
47+
default:
48+
return false
49+
}
50+
}
51+
52+
// sniffHost peeks the leading bytes of conn to recover the destination hostname
53+
// (TLS SNI or HTTP Host header) without consuming them: it returns the hostname
54+
// (without port; "" if none could be recovered) and a net.Conn that replays the
55+
// peeked bytes so the downstream tunnel still forwards the handshake/request
56+
// verbatim. A read deadline bounds the peek and is cleared before returning.
57+
func sniffHost(conn net.Conn) (string, net.Conn) {
58+
br := bufio.NewReaderSize(conn, sniffBufSize)
59+
wrapped := &peekedConn{Conn: conn, r: br}
60+
61+
_ = conn.SetReadDeadline(time.Now().Add(sniffTimeout))
62+
defer func() { _ = conn.SetReadDeadline(time.Time{}) }()
63+
64+
first, err := br.Peek(1)
65+
if err != nil || len(first) == 0 {
66+
return "", wrapped
67+
}
68+
switch {
69+
case first[0] == 0x16: // TLS handshake record
70+
return stripPort(sniffTLSSNI(br)), wrapped
71+
case first[0] >= 'A' && first[0] <= 'Z': // ASCII HTTP method
72+
return stripPort(sniffHTTPHost(br)), wrapped
73+
default:
74+
return "", wrapped
75+
}
76+
}
77+
78+
// sniffTLSSNI peeks the first TLS record (the ClientHello) and extracts the SNI.
79+
func sniffTLSSNI(br *bufio.Reader) string {
80+
hdr, err := br.Peek(5)
81+
if err != nil || len(hdr) < 5 {
82+
return ""
83+
}
84+
end := 5 + (int(hdr[3])<<8 | int(hdr[4]))
85+
if end > br.Size() {
86+
end = br.Size()
87+
}
88+
full, _ := br.Peek(end) // best effort: parse whatever is buffered
89+
return extractSNI(full)
90+
}
91+
92+
// extractSNI parses the SNI out of a buffered ClientHello by driving a throwaway
93+
// server-side handshake over the bytes and capturing ServerName in the config
94+
// callback, then aborting. Leans on crypto/tls's hardened parser rather than a
95+
// hand-rolled one. Returns "" if the bytes are not a parseable ClientHello.
96+
func extractSNI(clientHello []byte) string {
97+
var sni string
98+
_ = cryptotls.Server(readOnlyConn{r: bytes.NewReader(clientHello)}, &cryptotls.Config{
99+
GetConfigForClient: func(chi *cryptotls.ClientHelloInfo) (*cryptotls.Config, error) {
100+
sni = chi.ServerName
101+
return nil, errSniffDone
102+
},
103+
}).Handshake()
104+
return sni
105+
}
106+
107+
// sniffHTTPHost peeks the request header block and returns the Host header.
108+
func sniffHTTPHost(br *bufio.Reader) string {
109+
end := br.Buffered()
110+
if end < 1 {
111+
end = 1
112+
}
113+
for {
114+
buf, err := br.Peek(end)
115+
if i := bytes.Index(buf, []byte("\r\n\r\n")); i >= 0 {
116+
return parseHTTPHost(buf[:i+4])
117+
}
118+
if err != nil || end >= br.Size() {
119+
return parseHTTPHost(buf) // best effort on what we have
120+
}
121+
end++
122+
}
123+
}
124+
125+
func parseHTTPHost(headerBytes []byte) string {
126+
req, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(headerBytes)))
127+
if err != nil {
128+
return ""
129+
}
130+
return req.Host
131+
}
132+
133+
// stripPort drops a trailing :port if present (HTTP Host headers may carry one;
134+
// SNI never does). The real port comes from the SO_ORIGINAL_DST destination.
135+
func stripPort(host string) string {
136+
if host == "" {
137+
return ""
138+
}
139+
if h, _, err := net.SplitHostPort(host); err == nil {
140+
return h
141+
}
142+
return host
143+
}
144+
145+
// peekedConn is a net.Conn whose Read replays bytes buffered by a bufio.Reader
146+
// during sniffing, then continues from the underlying conn. Writes, Close,
147+
// deadlines, and addresses delegate to the embedded conn.
148+
type peekedConn struct {
149+
net.Conn
150+
r *bufio.Reader
151+
}
152+
153+
func (c *peekedConn) Read(p []byte) (int, error) { return c.r.Read(p) }
154+
155+
// readOnlyConn adapts a byte buffer to net.Conn for crypto/tls's server-side
156+
// parser. Reads come from the buffer; writes are discarded (the throwaway
157+
// handshake never needs to send), and the parser aborts via errSniffDone before
158+
// any write would matter.
159+
type readOnlyConn struct{ r io.Reader }
160+
161+
func (c readOnlyConn) Read(p []byte) (int, error) { return c.r.Read(p) }
162+
func (c readOnlyConn) Write(p []byte) (int, error) { return len(p), nil }
163+
func (c readOnlyConn) Close() error { return nil }
164+
func (c readOnlyConn) LocalAddr() net.Addr { return nil }
165+
func (c readOnlyConn) RemoteAddr() net.Addr { return nil }
166+
func (c readOnlyConn) SetDeadline(time.Time) error { return nil }
167+
func (c readOnlyConn) SetReadDeadline(time.Time) error { return nil }
168+
func (c readOnlyConn) SetWriteDeadline(time.Time) error { return nil }
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package forwardproxy
2+
3+
import (
4+
cryptotls "crypto/tls"
5+
"io"
6+
"net"
7+
"testing"
8+
"time"
9+
)
10+
11+
// readAll drains a conn until EOF/close, returning what was read. Used to prove
12+
// the sniffer replays the peeked bytes verbatim into the tunnel.
13+
func readAll(t *testing.T, c net.Conn) []byte {
14+
t.Helper()
15+
_ = c.SetReadDeadline(time.Now().Add(2 * time.Second))
16+
b, _ := io.ReadAll(c)
17+
return b
18+
}
19+
20+
// sniffServerSide accepts one connection on a fresh loopback listener, runs the
21+
// client closure against its address, and returns sniffHost's result plus the
22+
// bytes the wrapped conn yields (to verify replay).
23+
func sniffServerSide(t *testing.T, client func(addr string)) (string, []byte) {
24+
t.Helper()
25+
ln, err := net.Listen("tcp", "127.0.0.1:0")
26+
if err != nil {
27+
t.Fatalf("listen: %v", err)
28+
}
29+
defer func() { _ = ln.Close() }()
30+
31+
type result struct {
32+
host string
33+
replay []byte
34+
}
35+
ch := make(chan result, 1)
36+
go func() {
37+
conn, err := ln.Accept()
38+
if err != nil {
39+
ch <- result{}
40+
return
41+
}
42+
host, wrapped := sniffHost(conn)
43+
ch <- result{host: host, replay: readAll(t, wrapped)}
44+
}()
45+
46+
client(ln.Addr().String())
47+
48+
select {
49+
case r := <-ch:
50+
return r.host, r.replay
51+
case <-time.After(5 * time.Second):
52+
t.Fatal("timed out waiting for sniff result")
53+
return "", nil
54+
}
55+
}
56+
57+
// A real TLS ClientHello (generated by crypto/tls) with SNI must yield the SNI,
58+
// and the handshake bytes must still be replayed for the tunnel.
59+
func TestSniffHost_TLS_SNI(t *testing.T) {
60+
host, replay := sniffServerSide(t, func(addr string) {
61+
c, err := net.Dial("tcp", addr)
62+
if err != nil {
63+
t.Errorf("dial: %v", err)
64+
return
65+
}
66+
// Drive a real ClientHello with ServerName set. The handshake won't
67+
// complete (server side just sniffs and closes); we only need the
68+
// ClientHello on the wire.
69+
tlsConn := cryptotls.Client(c, &cryptotls.Config{
70+
ServerName: "api.openai.com",
71+
InsecureSkipVerify: true, //nolint:gosec // test-only; no real verification
72+
})
73+
_ = tlsConn.SetDeadline(time.Now().Add(1 * time.Second))
74+
_ = tlsConn.Handshake() // expected to fail; ClientHello is what matters
75+
_ = c.Close()
76+
})
77+
78+
if host != "api.openai.com" {
79+
t.Errorf("SNI host = %q, want api.openai.com", host)
80+
}
81+
if len(replay) == 0 || replay[0] != 0x16 {
82+
t.Errorf("replay did not start with a TLS record (0x16); got %d bytes", len(replay))
83+
}
84+
}
85+
86+
// A plaintext HTTP request must yield the Host header, port stripped, and the
87+
// full request must be replayed.
88+
func TestSniffHost_HTTP_Host(t *testing.T) {
89+
req := "GET /v1/models HTTP/1.1\r\nHost: api.example.org:8080\r\nUser-Agent: x\r\n\r\n"
90+
host, replay := sniffServerSide(t, func(addr string) {
91+
c, err := net.Dial("tcp", addr)
92+
if err != nil {
93+
t.Errorf("dial: %v", err)
94+
return
95+
}
96+
_, _ = c.Write([]byte(req))
97+
_ = c.Close()
98+
})
99+
100+
if host != "api.example.org" {
101+
t.Errorf("HTTP host = %q, want api.example.org (port stripped)", host)
102+
}
103+
if string(replay) != req {
104+
t.Errorf("replay = %q, want the original request verbatim", replay)
105+
}
106+
}
107+
108+
// Non-HTTP, non-TLS bytes yield no hostname but are still replayed verbatim.
109+
func TestSniffHost_Opaque(t *testing.T) {
110+
payload := []byte{0x00, 0x01, 0x02, 0x03, 0xff}
111+
host, replay := sniffServerSide(t, func(addr string) {
112+
c, err := net.Dial("tcp", addr)
113+
if err != nil {
114+
t.Errorf("dial: %v", err)
115+
return
116+
}
117+
_, _ = c.Write(payload)
118+
_ = c.Close()
119+
})
120+
121+
if host != "" {
122+
t.Errorf("opaque host = %q, want empty", host)
123+
}
124+
if string(replay) != string(payload) {
125+
t.Errorf("replay = %v, want %v", replay, payload)
126+
}
127+
}
128+
129+
func TestShouldSniff(t *testing.T) {
130+
cases := map[string]bool{
131+
"1.2.3.4:443": true,
132+
"1.2.3.4:80": true,
133+
"1.2.3.4:8443": true,
134+
"1.2.3.4:22": false,
135+
"1.2.3.4:5432": false,
136+
"[::1]:443": true,
137+
"garbage": false,
138+
}
139+
for in, want := range cases {
140+
if got := shouldSniff(in); got != want {
141+
t.Errorf("shouldSniff(%q) = %v, want %v", in, got, want)
142+
}
143+
}
144+
}

0 commit comments

Comments
 (0)