| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328 |
- package tlsfingerprint
- import (
- "context"
- "crypto/tls"
- "crypto/x509"
- "encoding/binary"
- "io"
- "net"
- "net/http"
- "net/http/httptest"
- "net/url"
- "reflect"
- "strings"
- "sync/atomic"
- "testing"
- "time"
- )
- type localHello struct {
- ciphers []uint16
- curves []tls.CurveID
- alpn []string
- }
- func localTLSTarget(t *testing.T) (*httptest.Server, *x509.CertPool, chan localHello, *atomic.Int64) {
- t.Helper()
- hellos := make(chan localHello, 16)
- calls := &atomic.Int64{}
- s := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- calls.Add(1)
- _, _ = io.Copy(w, r.Body)
- }))
- s.TLS = &tls.Config{GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config, error) {
- hellos <- localHello{append([]uint16(nil), hello.CipherSuites...), append([]tls.CurveID(nil), hello.SupportedCurves...), append([]string(nil), hello.SupportedProtos...)}
- return nil, nil
- }}
- s.StartTLS()
- t.Cleanup(s.Close)
- roots := x509.NewCertPool()
- roots.AddCert(s.Certificate())
- return s, roots, hellos, calls
- }
- func assertLocalHello(t *testing.T, got localHello, p *Profile) {
- t.Helper()
- if !reflect.DeepEqual(got.ciphers, p.CipherSuites) {
- t.Fatalf("actual ClientHello cipher list differs from profile: got %v want %v", got.ciphers, p.CipherSuites)
- }
- curves := make([]tls.CurveID, len(p.Curves))
- for i, curve := range p.Curves {
- curves[i] = tls.CurveID(curve)
- }
- if !reflect.DeepEqual(got.curves, curves) || !reflect.DeepEqual(got.alpn, []string{"http/1.1"}) {
- t.Fatalf("actual ClientHello curves/ALPN differ: %+v", got)
- }
- }
- func localRoundTrip(t *testing.T, transport *http.Transport, target string) {
- t.Helper()
- client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
- const payload = `{"model":"test-model","reasoning":{"effort":"high"},"input":[{"role":"user","content":"complete input"}]}`
- resp, err := client.Post(target, "application/json", strings.NewReader(payload))
- if err != nil {
- t.Fatal(err)
- }
- defer resp.Body.Close()
- body, err := io.ReadAll(resp.Body)
- if err != nil || string(body) != payload || resp.StatusCode != http.StatusOK {
- t.Fatalf("TLS round trip changed payload: status=%d body=%q err=%v", resp.StatusCode, body, err)
- }
- }
- func TestLocalTLSHTTPActualHelloAndImmutableProfile(t *testing.T) {
- target, roots, hellos, calls := localTLSTarget(t)
- p := BuiltinProfile("nodejs24")
- expected := p.Clone()
- transport, err := NewHTTPTransport(p, nil, TransportOptions{RootCAs: roots})
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(transport.CloseIdleConnections)
- p.CipherSuites[0] = 0xffff
- p.Curves[0] = 0xffff
- localRoundTrip(t, transport, target.URL)
- localRoundTrip(t, transport, target.URL)
- assertLocalHello(t, <-hellos, expected)
- if len(hellos) != 0 || calls.Load() != 2 {
- t.Fatal("same transport did not reuse the verified TLS connection")
- }
- }
- func TestLocalTLSRejectsUntrustedCertificate(t *testing.T) {
- target, _, hellos, calls := localTLSTarget(t)
- transport, err := NewHTTPTransport(BuiltinProfile("nodejs24"), nil, TransportOptions{})
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(transport.CloseIdleConnections)
- _, err = (&http.Client{Transport: transport, Timeout: 3 * time.Second}).Get(target.URL)
- if err == nil || !strings.Contains(err.Error(), "certificate") {
- t.Fatalf("untrusted certificate must be rejected: %v", err)
- }
- if calls.Load() != 0 || len(hellos) != 1 {
- t.Fatal("untrusted certificate triggered application traffic or a fallback attempt")
- }
- }
- func localConnectProxy(t *testing.T, secure bool) (*httptest.Server, *atomic.Int64) {
- t.Helper()
- calls := &atomic.Int64{}
- s := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- calls.Add(1)
- if r.Method != http.MethodConnect || r.Header.Get("Proxy-Authorization") != "Basic dGVzdDp0ZXN0" {
- w.WriteHeader(http.StatusProxyAuthRequired)
- return
- }
- upstream, err := net.DialTimeout("tcp", r.Host, time.Second)
- if err != nil {
- w.WriteHeader(http.StatusBadGateway)
- return
- }
- downstream, _, err := w.(http.Hijacker).Hijack()
- if err != nil {
- upstream.Close()
- return
- }
- _, _ = io.WriteString(downstream, "HTTP/1.1 200 Connection Established\r\n\r\n")
- go func() {
- defer upstream.Close()
- defer downstream.Close()
- _, _ = io.Copy(upstream, downstream)
- }()
- go func() {
- defer upstream.Close()
- defer downstream.Close()
- _, _ = io.Copy(downstream, upstream)
- }()
- }))
- if secure {
- s.StartTLS()
- } else {
- s.Start()
- }
- t.Cleanup(s.Close)
- return s, calls
- }
- func TestLocalTLSHTTPAndHTTPSConnectUseProfile(t *testing.T) {
- for _, secure := range []bool{false, true} {
- name := "http"
- if secure {
- name = "https"
- }
- t.Run(name, func(t *testing.T) {
- target, roots, hellos, _ := localTLSTarget(t)
- proxy, proxyCalls := localConnectProxy(t, secure)
- proxyURL, _ := url.Parse(proxy.URL)
- proxyURL.User = url.UserPassword("test", "test")
- opts := TransportOptions{RootCAs: roots}
- if secure {
- opts.ProxyRootCAs = x509.NewCertPool()
- opts.ProxyRootCAs.AddCert(proxy.Certificate())
- }
- p := BuiltinProfile("nodejs24")
- transport, err := NewHTTPTransport(p, proxyURL, opts)
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(transport.CloseIdleConnections)
- localRoundTrip(t, transport, target.URL)
- assertLocalHello(t, <-hellos, p)
- if proxyCalls.Load() != 1 {
- t.Fatal("TLS request bypassed configured CONNECT proxy")
- }
- })
- }
- }
- func TestLocalTLSProxyErrorsNeverBypassProxy(t *testing.T) {
- target, roots, hellos, calls := localTLSTarget(t)
- for _, secure := range []bool{false, true} {
- proxy, _ := localConnectProxy(t, secure)
- proxyURL, _ := url.Parse(proxy.URL)
- // No authentication for HTTP; no trusted proxy CA for HTTPS.
- transport, err := NewHTTPTransport(BuiltinProfile("nodejs24"), proxyURL, TransportOptions{RootCAs: roots})
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(transport.CloseIdleConnections)
- _, err = (&http.Client{Transport: transport, Timeout: 3 * time.Second}).Get(target.URL)
- if err == nil {
- t.Fatal("failed proxy unexpectedly allowed a request")
- }
- }
- if calls.Load() != 0 || len(hellos) != 0 {
- t.Fatal("proxy error fell back to a direct upstream connection")
- }
- }
- func TestLocalTLSSOCKSUsesProfile(t *testing.T) {
- target, roots, hellos, _ := localTLSTarget(t)
- listener, err := net.Listen("tcp", "127.0.0.1:0")
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { _ = listener.Close() })
- go func() {
- conn, err := listener.Accept()
- if err != nil {
- return
- }
- defer conn.Close()
- buf := make([]byte, 2)
- if _, err = io.ReadFull(conn, buf); err != nil {
- return
- }
- methods := make([]byte, int(buf[1]))
- if _, err = io.ReadFull(conn, methods); err != nil {
- return
- }
- _, _ = conn.Write([]byte{5, 0})
- header := make([]byte, 4)
- if _, err = io.ReadFull(conn, header); err != nil || header[3] != 1 {
- return
- }
- addr := make([]byte, 6)
- if _, err = io.ReadFull(conn, addr); err != nil {
- return
- }
- if net.IP(addr[:4]).String() != "127.0.0.1" || binary.BigEndian.Uint16(addr[4:]) == 0 {
- return
- }
- upstream, err := net.DialTimeout("tcp", strings.TrimPrefix(target.URL, "https://"), time.Second)
- if err != nil {
- return
- }
- defer upstream.Close()
- _, _ = conn.Write([]byte{5, 0, 0, 1, 127, 0, 0, 1, 0, 0})
- go func() { _, _ = io.Copy(upstream, conn); _ = upstream.Close() }()
- _, _ = io.Copy(conn, upstream)
- }()
- proxyURL, _ := url.Parse("socks5://" + listener.Addr().String())
- p := BuiltinProfile("nodejs24")
- transport, err := NewHTTPTransport(p, proxyURL, TransportOptions{RootCAs: roots})
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(transport.CloseIdleConnections)
- localRoundTrip(t, transport, target.URL)
- assertLocalHello(t, <-hellos, p)
- }
- func TestLocalTLSConnectCancellation(t *testing.T) {
- listener, err := net.Listen("tcp", "127.0.0.1:0")
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { _ = listener.Close() })
- done := make(chan struct{})
- go func() {
- defer close(done)
- conn, err := listener.Accept()
- if err != nil {
- return
- }
- defer conn.Close()
- _, _ = io.Copy(io.Discard, conn) // accept CONNECT, never answer it
- }()
- proxyURL, _ := url.Parse("http://" + listener.Addr().String())
- transport, err := NewHTTPTransport(BuiltinProfile("nodejs24"), proxyURL, TransportOptions{HandshakeTimeout: 100 * time.Millisecond})
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(transport.CloseIdleConnections)
- req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://localhost:443", nil)
- _, err = (&http.Client{Transport: transport, Timeout: time.Second}).Do(req)
- if err == nil {
- t.Fatal("stalled CONNECT did not time out")
- }
- select {
- case <-done:
- case <-time.After(time.Second):
- t.Fatal("cancellation did not close proxy connection")
- }
- }
- func TestLocalTLSRejectsUnsupportedRoutes(t *testing.T) {
- p := BuiltinProfile("nodejs24")
- badProxy, _ := url.Parse("ftp://127.0.0.1:1")
- if _, err := NewHTTPTransport(p, badProxy, TransportOptions{}); err == nil {
- t.Fatal("unsupported proxy must fail at construction")
- }
- p.ALPNProtocols = []string{"h2", "http/1.1"}
- if _, err := NewHTTPTransport(p, nil, TransportOptions{}); err == nil {
- t.Fatal("unsupported HTTP/2 negotiation must fail at construction")
- }
- transport, err := NewHTTPTransport(BuiltinProfile("nodejs24"), nil, TransportOptions{})
- if err != nil {
- t.Fatal(err)
- }
- if _, err := (&http.Client{Transport: transport}).Get("http://127.0.0.1:1"); err == nil || !strings.Contains(err.Error(), "requires HTTPS") {
- t.Fatalf("plaintext route was not rejected before dialing: %v", err)
- }
- }
- func TestLocalTLSProfileCloneAndContentKey(t *testing.T) {
- p := BuiltinProfile("nodejs24")
- c := p.Clone()
- if c.CacheKey() != p.CacheKey() {
- t.Fatal("clone changed template key")
- }
- for name, mutate := range map[string]func(*Profile){
- "ciphers": func(c *Profile) { c.CipherSuites[0], c.CipherSuites[1] = c.CipherSuites[1], c.CipherSuites[0] },
- "curves": func(c *Profile) { c.Curves[0], c.Curves[1] = c.Curves[1], c.Curves[0] },
- "extensions": func(c *Profile) { c.Extensions[0], c.Extensions[1] = c.Extensions[1], c.Extensions[0] },
- "alpn": func(c *Profile) { c.ALPNProtocols[0] = "different" },
- } {
- t.Run(name, func(t *testing.T) {
- original := p.CacheKey()
- copy := p.Clone()
- mutate(copy)
- if copy.CacheKey() == original || p.CacheKey() != original {
- t.Fatal("template edit reused key or modified original profile")
- }
- })
- }
- }
|