transport_local_test.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. package tlsfingerprint
  2. import (
  3. "context"
  4. "crypto/tls"
  5. "crypto/x509"
  6. "encoding/binary"
  7. "io"
  8. "net"
  9. "net/http"
  10. "net/http/httptest"
  11. "net/url"
  12. "reflect"
  13. "strings"
  14. "sync/atomic"
  15. "testing"
  16. "time"
  17. )
  18. type localHello struct {
  19. ciphers []uint16
  20. curves []tls.CurveID
  21. alpn []string
  22. }
  23. func localTLSTarget(t *testing.T) (*httptest.Server, *x509.CertPool, chan localHello, *atomic.Int64) {
  24. t.Helper()
  25. hellos := make(chan localHello, 16)
  26. calls := &atomic.Int64{}
  27. s := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  28. calls.Add(1)
  29. _, _ = io.Copy(w, r.Body)
  30. }))
  31. s.TLS = &tls.Config{GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config, error) {
  32. hellos <- localHello{append([]uint16(nil), hello.CipherSuites...), append([]tls.CurveID(nil), hello.SupportedCurves...), append([]string(nil), hello.SupportedProtos...)}
  33. return nil, nil
  34. }}
  35. s.StartTLS()
  36. t.Cleanup(s.Close)
  37. roots := x509.NewCertPool()
  38. roots.AddCert(s.Certificate())
  39. return s, roots, hellos, calls
  40. }
  41. func assertLocalHello(t *testing.T, got localHello, p *Profile) {
  42. t.Helper()
  43. if !reflect.DeepEqual(got.ciphers, p.CipherSuites) {
  44. t.Fatalf("actual ClientHello cipher list differs from profile: got %v want %v", got.ciphers, p.CipherSuites)
  45. }
  46. curves := make([]tls.CurveID, len(p.Curves))
  47. for i, curve := range p.Curves {
  48. curves[i] = tls.CurveID(curve)
  49. }
  50. if !reflect.DeepEqual(got.curves, curves) || !reflect.DeepEqual(got.alpn, []string{"http/1.1"}) {
  51. t.Fatalf("actual ClientHello curves/ALPN differ: %+v", got)
  52. }
  53. }
  54. func localRoundTrip(t *testing.T, transport *http.Transport, target string) {
  55. t.Helper()
  56. client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
  57. const payload = `{"model":"test-model","reasoning":{"effort":"high"},"input":[{"role":"user","content":"complete input"}]}`
  58. resp, err := client.Post(target, "application/json", strings.NewReader(payload))
  59. if err != nil {
  60. t.Fatal(err)
  61. }
  62. defer resp.Body.Close()
  63. body, err := io.ReadAll(resp.Body)
  64. if err != nil || string(body) != payload || resp.StatusCode != http.StatusOK {
  65. t.Fatalf("TLS round trip changed payload: status=%d body=%q err=%v", resp.StatusCode, body, err)
  66. }
  67. }
  68. func TestLocalTLSHTTPActualHelloAndImmutableProfile(t *testing.T) {
  69. target, roots, hellos, calls := localTLSTarget(t)
  70. p := BuiltinProfile("nodejs24")
  71. expected := p.Clone()
  72. transport, err := NewHTTPTransport(p, nil, TransportOptions{RootCAs: roots})
  73. if err != nil {
  74. t.Fatal(err)
  75. }
  76. t.Cleanup(transport.CloseIdleConnections)
  77. p.CipherSuites[0] = 0xffff
  78. p.Curves[0] = 0xffff
  79. localRoundTrip(t, transport, target.URL)
  80. localRoundTrip(t, transport, target.URL)
  81. assertLocalHello(t, <-hellos, expected)
  82. if len(hellos) != 0 || calls.Load() != 2 {
  83. t.Fatal("same transport did not reuse the verified TLS connection")
  84. }
  85. }
  86. func TestLocalTLSRejectsUntrustedCertificate(t *testing.T) {
  87. target, _, hellos, calls := localTLSTarget(t)
  88. transport, err := NewHTTPTransport(BuiltinProfile("nodejs24"), nil, TransportOptions{})
  89. if err != nil {
  90. t.Fatal(err)
  91. }
  92. t.Cleanup(transport.CloseIdleConnections)
  93. _, err = (&http.Client{Transport: transport, Timeout: 3 * time.Second}).Get(target.URL)
  94. if err == nil || !strings.Contains(err.Error(), "certificate") {
  95. t.Fatalf("untrusted certificate must be rejected: %v", err)
  96. }
  97. if calls.Load() != 0 || len(hellos) != 1 {
  98. t.Fatal("untrusted certificate triggered application traffic or a fallback attempt")
  99. }
  100. }
  101. func localConnectProxy(t *testing.T, secure bool) (*httptest.Server, *atomic.Int64) {
  102. t.Helper()
  103. calls := &atomic.Int64{}
  104. s := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  105. calls.Add(1)
  106. if r.Method != http.MethodConnect || r.Header.Get("Proxy-Authorization") != "Basic dGVzdDp0ZXN0" {
  107. w.WriteHeader(http.StatusProxyAuthRequired)
  108. return
  109. }
  110. upstream, err := net.DialTimeout("tcp", r.Host, time.Second)
  111. if err != nil {
  112. w.WriteHeader(http.StatusBadGateway)
  113. return
  114. }
  115. downstream, _, err := w.(http.Hijacker).Hijack()
  116. if err != nil {
  117. upstream.Close()
  118. return
  119. }
  120. _, _ = io.WriteString(downstream, "HTTP/1.1 200 Connection Established\r\n\r\n")
  121. go func() {
  122. defer upstream.Close()
  123. defer downstream.Close()
  124. _, _ = io.Copy(upstream, downstream)
  125. }()
  126. go func() {
  127. defer upstream.Close()
  128. defer downstream.Close()
  129. _, _ = io.Copy(downstream, upstream)
  130. }()
  131. }))
  132. if secure {
  133. s.StartTLS()
  134. } else {
  135. s.Start()
  136. }
  137. t.Cleanup(s.Close)
  138. return s, calls
  139. }
  140. func TestLocalTLSHTTPAndHTTPSConnectUseProfile(t *testing.T) {
  141. for _, secure := range []bool{false, true} {
  142. name := "http"
  143. if secure {
  144. name = "https"
  145. }
  146. t.Run(name, func(t *testing.T) {
  147. target, roots, hellos, _ := localTLSTarget(t)
  148. proxy, proxyCalls := localConnectProxy(t, secure)
  149. proxyURL, _ := url.Parse(proxy.URL)
  150. proxyURL.User = url.UserPassword("test", "test")
  151. opts := TransportOptions{RootCAs: roots}
  152. if secure {
  153. opts.ProxyRootCAs = x509.NewCertPool()
  154. opts.ProxyRootCAs.AddCert(proxy.Certificate())
  155. }
  156. p := BuiltinProfile("nodejs24")
  157. transport, err := NewHTTPTransport(p, proxyURL, opts)
  158. if err != nil {
  159. t.Fatal(err)
  160. }
  161. t.Cleanup(transport.CloseIdleConnections)
  162. localRoundTrip(t, transport, target.URL)
  163. assertLocalHello(t, <-hellos, p)
  164. if proxyCalls.Load() != 1 {
  165. t.Fatal("TLS request bypassed configured CONNECT proxy")
  166. }
  167. })
  168. }
  169. }
  170. func TestLocalTLSProxyErrorsNeverBypassProxy(t *testing.T) {
  171. target, roots, hellos, calls := localTLSTarget(t)
  172. for _, secure := range []bool{false, true} {
  173. proxy, _ := localConnectProxy(t, secure)
  174. proxyURL, _ := url.Parse(proxy.URL)
  175. // No authentication for HTTP; no trusted proxy CA for HTTPS.
  176. transport, err := NewHTTPTransport(BuiltinProfile("nodejs24"), proxyURL, TransportOptions{RootCAs: roots})
  177. if err != nil {
  178. t.Fatal(err)
  179. }
  180. t.Cleanup(transport.CloseIdleConnections)
  181. _, err = (&http.Client{Transport: transport, Timeout: 3 * time.Second}).Get(target.URL)
  182. if err == nil {
  183. t.Fatal("failed proxy unexpectedly allowed a request")
  184. }
  185. }
  186. if calls.Load() != 0 || len(hellos) != 0 {
  187. t.Fatal("proxy error fell back to a direct upstream connection")
  188. }
  189. }
  190. func TestLocalTLSSOCKSUsesProfile(t *testing.T) {
  191. target, roots, hellos, _ := localTLSTarget(t)
  192. listener, err := net.Listen("tcp", "127.0.0.1:0")
  193. if err != nil {
  194. t.Fatal(err)
  195. }
  196. t.Cleanup(func() { _ = listener.Close() })
  197. go func() {
  198. conn, err := listener.Accept()
  199. if err != nil {
  200. return
  201. }
  202. defer conn.Close()
  203. buf := make([]byte, 2)
  204. if _, err = io.ReadFull(conn, buf); err != nil {
  205. return
  206. }
  207. methods := make([]byte, int(buf[1]))
  208. if _, err = io.ReadFull(conn, methods); err != nil {
  209. return
  210. }
  211. _, _ = conn.Write([]byte{5, 0})
  212. header := make([]byte, 4)
  213. if _, err = io.ReadFull(conn, header); err != nil || header[3] != 1 {
  214. return
  215. }
  216. addr := make([]byte, 6)
  217. if _, err = io.ReadFull(conn, addr); err != nil {
  218. return
  219. }
  220. if net.IP(addr[:4]).String() != "127.0.0.1" || binary.BigEndian.Uint16(addr[4:]) == 0 {
  221. return
  222. }
  223. upstream, err := net.DialTimeout("tcp", strings.TrimPrefix(target.URL, "https://"), time.Second)
  224. if err != nil {
  225. return
  226. }
  227. defer upstream.Close()
  228. _, _ = conn.Write([]byte{5, 0, 0, 1, 127, 0, 0, 1, 0, 0})
  229. go func() { _, _ = io.Copy(upstream, conn); _ = upstream.Close() }()
  230. _, _ = io.Copy(conn, upstream)
  231. }()
  232. proxyURL, _ := url.Parse("socks5://" + listener.Addr().String())
  233. p := BuiltinProfile("nodejs24")
  234. transport, err := NewHTTPTransport(p, proxyURL, TransportOptions{RootCAs: roots})
  235. if err != nil {
  236. t.Fatal(err)
  237. }
  238. t.Cleanup(transport.CloseIdleConnections)
  239. localRoundTrip(t, transport, target.URL)
  240. assertLocalHello(t, <-hellos, p)
  241. }
  242. func TestLocalTLSConnectCancellation(t *testing.T) {
  243. listener, err := net.Listen("tcp", "127.0.0.1:0")
  244. if err != nil {
  245. t.Fatal(err)
  246. }
  247. t.Cleanup(func() { _ = listener.Close() })
  248. done := make(chan struct{})
  249. go func() {
  250. defer close(done)
  251. conn, err := listener.Accept()
  252. if err != nil {
  253. return
  254. }
  255. defer conn.Close()
  256. _, _ = io.Copy(io.Discard, conn) // accept CONNECT, never answer it
  257. }()
  258. proxyURL, _ := url.Parse("http://" + listener.Addr().String())
  259. transport, err := NewHTTPTransport(BuiltinProfile("nodejs24"), proxyURL, TransportOptions{HandshakeTimeout: 100 * time.Millisecond})
  260. if err != nil {
  261. t.Fatal(err)
  262. }
  263. t.Cleanup(transport.CloseIdleConnections)
  264. req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://localhost:443", nil)
  265. _, err = (&http.Client{Transport: transport, Timeout: time.Second}).Do(req)
  266. if err == nil {
  267. t.Fatal("stalled CONNECT did not time out")
  268. }
  269. select {
  270. case <-done:
  271. case <-time.After(time.Second):
  272. t.Fatal("cancellation did not close proxy connection")
  273. }
  274. }
  275. func TestLocalTLSRejectsUnsupportedRoutes(t *testing.T) {
  276. p := BuiltinProfile("nodejs24")
  277. badProxy, _ := url.Parse("ftp://127.0.0.1:1")
  278. if _, err := NewHTTPTransport(p, badProxy, TransportOptions{}); err == nil {
  279. t.Fatal("unsupported proxy must fail at construction")
  280. }
  281. p.ALPNProtocols = []string{"h2", "http/1.1"}
  282. if _, err := NewHTTPTransport(p, nil, TransportOptions{}); err == nil {
  283. t.Fatal("unsupported HTTP/2 negotiation must fail at construction")
  284. }
  285. transport, err := NewHTTPTransport(BuiltinProfile("nodejs24"), nil, TransportOptions{})
  286. if err != nil {
  287. t.Fatal(err)
  288. }
  289. if _, err := (&http.Client{Transport: transport}).Get("http://127.0.0.1:1"); err == nil || !strings.Contains(err.Error(), "requires HTTPS") {
  290. t.Fatalf("plaintext route was not rejected before dialing: %v", err)
  291. }
  292. }
  293. func TestLocalTLSProfileCloneAndContentKey(t *testing.T) {
  294. p := BuiltinProfile("nodejs24")
  295. c := p.Clone()
  296. if c.CacheKey() != p.CacheKey() {
  297. t.Fatal("clone changed template key")
  298. }
  299. for name, mutate := range map[string]func(*Profile){
  300. "ciphers": func(c *Profile) { c.CipherSuites[0], c.CipherSuites[1] = c.CipherSuites[1], c.CipherSuites[0] },
  301. "curves": func(c *Profile) { c.Curves[0], c.Curves[1] = c.Curves[1], c.Curves[0] },
  302. "extensions": func(c *Profile) { c.Extensions[0], c.Extensions[1] = c.Extensions[1], c.Extensions[0] },
  303. "alpn": func(c *Profile) { c.ALPNProtocols[0] = "different" },
  304. } {
  305. t.Run(name, func(t *testing.T) {
  306. original := p.CacheKey()
  307. copy := p.Clone()
  308. mutate(copy)
  309. if copy.CacheKey() == original || p.CacheKey() != original {
  310. t.Fatal("template edit reused key or modified original profile")
  311. }
  312. })
  313. }
  314. }