955 lines
28 KiB
Go
955 lines
28 KiB
Go
//go:build darwin && cgo
|
|
|
|
package httpclient
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
stdtls "crypto/tls"
|
|
"crypto/x509"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
"unsafe"
|
|
|
|
"github.com/sagernet/sing-box/adapter"
|
|
"github.com/sagernet/sing-box/common/proxybridge"
|
|
boxTLS "github.com/sagernet/sing-box/common/tls"
|
|
"github.com/sagernet/sing-box/log"
|
|
"github.com/sagernet/sing-box/option"
|
|
"github.com/sagernet/sing-box/route"
|
|
"github.com/sagernet/sing/common/json/badoption"
|
|
commonLogger "github.com/sagernet/sing/common/logger"
|
|
M "github.com/sagernet/sing/common/metadata"
|
|
N "github.com/sagernet/sing/common/network"
|
|
"github.com/sagernet/sing/service"
|
|
)
|
|
|
|
const appleHTTPTestTimeout = 5 * time.Second
|
|
|
|
const appleHTTPRecoveryLoops = 5
|
|
|
|
type appleHTTPTestDialer struct {
|
|
dialer net.Dialer
|
|
listener net.ListenConfig
|
|
hostMap map[string]string
|
|
}
|
|
|
|
type appleHTTPObservedRequest struct {
|
|
method string
|
|
body string
|
|
host string
|
|
values []string
|
|
protoMajor int
|
|
}
|
|
|
|
type appleHTTPTestServer struct {
|
|
server *httptest.Server
|
|
baseURL string
|
|
dialHost string
|
|
certificate stdtls.Certificate
|
|
certificatePEM string
|
|
publicKeyHash []byte
|
|
}
|
|
|
|
type appleTestAnchors struct {
|
|
ref unsafe.Pointer
|
|
releases int
|
|
}
|
|
|
|
func (a *appleTestAnchors) Retain() adapter.AppleAnchors {
|
|
return a
|
|
}
|
|
|
|
func (a *appleTestAnchors) Release() {
|
|
a.releases++
|
|
}
|
|
|
|
func (a *appleTestAnchors) Ref() unsafe.Pointer {
|
|
return a.ref
|
|
}
|
|
|
|
func TestNewAppleSessionConfig(t *testing.T) {
|
|
serverCertificate, serverCertificatePEM := newAppleHTTPTestCertificate(t, "localhost")
|
|
serverHash := certificatePublicKeySHA256(t, serverCertificate.Certificate[0])
|
|
otherHash := bytes.Repeat([]byte{0x7f}, applePinnedHashSize)
|
|
|
|
testCases := []struct {
|
|
name string
|
|
options option.HTTPClientOptions
|
|
check func(t *testing.T, config appleSessionConfig)
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "success with certificate anchors",
|
|
options: option.HTTPClientOptions{
|
|
Version: 2,
|
|
DialerOptions: option.DialerOptions{
|
|
AbstractDialerOptions: option.AbstractDialerOptions{
|
|
ConnectTimeout: badoption.Duration(2 * time.Second),
|
|
},
|
|
},
|
|
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
|
|
TLS: &option.OutboundTLSOptions{
|
|
Enabled: true,
|
|
ServerName: "localhost",
|
|
MinVersion: "1.2",
|
|
MaxVersion: "1.3",
|
|
Certificate: badoption.Listable[string]{serverCertificatePEM},
|
|
},
|
|
},
|
|
},
|
|
check: func(t *testing.T, config appleSessionConfig) {
|
|
t.Helper()
|
|
if config.serverName != "localhost" {
|
|
t.Fatalf("unexpected server name: %q", config.serverName)
|
|
}
|
|
if config.minVersion != stdtls.VersionTLS12 {
|
|
t.Fatalf("unexpected min version: %x", config.minVersion)
|
|
}
|
|
if config.maxVersion != stdtls.VersionTLS13 {
|
|
t.Fatalf("unexpected max version: %x", config.maxVersion)
|
|
}
|
|
if config.insecure {
|
|
t.Fatal("unexpected insecure flag")
|
|
}
|
|
if !config.anchorOnly {
|
|
t.Fatal("expected anchor_only")
|
|
}
|
|
if config.userAnchors == nil {
|
|
t.Fatal("expected user anchors")
|
|
}
|
|
if config.userAnchors.Ref() == nil {
|
|
t.Fatal("expected non-empty user anchors")
|
|
}
|
|
if config.store != nil {
|
|
t.Fatal("unexpected store reference")
|
|
}
|
|
if len(config.pinnedPublicKeySHA256s) != 0 {
|
|
t.Fatalf("unexpected pinned hashes: %d", len(config.pinnedPublicKeySHA256s))
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "success with flattened pins",
|
|
options: option.HTTPClientOptions{
|
|
Version: 2,
|
|
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
|
|
TLS: &option.OutboundTLSOptions{
|
|
Enabled: true,
|
|
Insecure: true,
|
|
CertificatePublicKeySHA256: badoption.Listable[[]byte]{serverHash, otherHash},
|
|
},
|
|
},
|
|
},
|
|
check: func(t *testing.T, config appleSessionConfig) {
|
|
t.Helper()
|
|
if !config.insecure {
|
|
t.Fatal("expected insecure flag")
|
|
}
|
|
if len(config.pinnedPublicKeySHA256s) != 2*applePinnedHashSize {
|
|
t.Fatalf("unexpected flattened pin length: %d", len(config.pinnedPublicKeySHA256s))
|
|
}
|
|
if !bytes.Equal(config.pinnedPublicKeySHA256s[:applePinnedHashSize], serverHash) {
|
|
t.Fatal("unexpected first pin")
|
|
}
|
|
if !bytes.Equal(config.pinnedPublicKeySHA256s[applePinnedHashSize:], otherHash) {
|
|
t.Fatal("unexpected second pin")
|
|
}
|
|
if config.userAnchors != nil {
|
|
t.Fatal("unexpected user anchors")
|
|
}
|
|
if config.anchorOnly {
|
|
t.Fatal("unexpected anchor_only")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "http11 unsupported",
|
|
options: option.HTTPClientOptions{Version: 1},
|
|
wantErr: "HTTP/1.1 is unsupported in Apple HTTP engine",
|
|
},
|
|
{
|
|
name: "http3 unsupported",
|
|
options: option.HTTPClientOptions{Version: 3},
|
|
wantErr: "HTTP/3 is unsupported in Apple HTTP engine",
|
|
},
|
|
{
|
|
name: "unknown version",
|
|
options: option.HTTPClientOptions{Version: 9},
|
|
wantErr: "unknown HTTP version: 9",
|
|
},
|
|
{
|
|
name: "disable version fallback unsupported",
|
|
options: option.HTTPClientOptions{
|
|
DisableVersionFallback: true,
|
|
},
|
|
wantErr: "disable_version_fallback is unsupported in Apple HTTP engine",
|
|
},
|
|
{
|
|
name: "http2 options unsupported",
|
|
options: option.HTTPClientOptions{
|
|
HTTP2Options: option.HTTP2Options{
|
|
IdleTimeout: badoption.Duration(time.Second),
|
|
},
|
|
},
|
|
wantErr: "HTTP/2 options are unsupported in Apple HTTP engine",
|
|
},
|
|
{
|
|
name: "quic options unsupported",
|
|
options: option.HTTPClientOptions{
|
|
HTTP3Options: option.QUICOptions{
|
|
InitialPacketSize: 1200,
|
|
},
|
|
},
|
|
wantErr: "QUIC options are unsupported in Apple HTTP engine",
|
|
},
|
|
{
|
|
name: "tls engine unsupported",
|
|
options: option.HTTPClientOptions{
|
|
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
|
|
TLS: &option.OutboundTLSOptions{Engine: "go"},
|
|
},
|
|
},
|
|
wantErr: "tls.engine is unsupported in Apple HTTP engine",
|
|
},
|
|
{
|
|
name: "disable sni unsupported",
|
|
options: option.HTTPClientOptions{
|
|
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
|
|
TLS: &option.OutboundTLSOptions{DisableSNI: true},
|
|
},
|
|
},
|
|
wantErr: "disable_sni is unsupported in Apple HTTP engine",
|
|
},
|
|
{
|
|
name: "alpn unsupported",
|
|
options: option.HTTPClientOptions{
|
|
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
|
|
TLS: &option.OutboundTLSOptions{
|
|
ALPN: badoption.Listable[string]{"h2"},
|
|
},
|
|
},
|
|
},
|
|
wantErr: "tls.alpn is unsupported in Apple HTTP engine",
|
|
},
|
|
{
|
|
name: "cipher suites unsupported",
|
|
options: option.HTTPClientOptions{
|
|
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
|
|
TLS: &option.OutboundTLSOptions{
|
|
CipherSuites: badoption.Listable[string]{"TLS_AES_128_GCM_SHA256"},
|
|
},
|
|
},
|
|
},
|
|
wantErr: "cipher_suites is unsupported in Apple HTTP engine",
|
|
},
|
|
{
|
|
name: "curve preferences unsupported",
|
|
options: option.HTTPClientOptions{
|
|
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
|
|
TLS: &option.OutboundTLSOptions{
|
|
CurvePreferences: badoption.Listable[option.CurvePreference]{option.CurvePreference(option.X25519)},
|
|
},
|
|
},
|
|
},
|
|
wantErr: "curve_preferences is unsupported in Apple HTTP engine",
|
|
},
|
|
{
|
|
name: "client certificate unsupported",
|
|
options: option.HTTPClientOptions{
|
|
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
|
|
TLS: &option.OutboundTLSOptions{
|
|
ClientCertificate: badoption.Listable[string]{"client-certificate"},
|
|
ClientKey: badoption.Listable[string]{"client-key"},
|
|
},
|
|
},
|
|
},
|
|
wantErr: "client certificate is unsupported in Apple HTTP engine",
|
|
},
|
|
{
|
|
name: "tls fragment unsupported",
|
|
options: option.HTTPClientOptions{
|
|
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
|
|
TLS: &option.OutboundTLSOptions{Fragment: true},
|
|
},
|
|
},
|
|
wantErr: "tls fragment is unsupported in Apple HTTP engine",
|
|
},
|
|
{
|
|
name: "ktls unsupported",
|
|
options: option.HTTPClientOptions{
|
|
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
|
|
TLS: &option.OutboundTLSOptions{KernelTx: true},
|
|
},
|
|
},
|
|
wantErr: "ktls is unsupported in Apple HTTP engine",
|
|
},
|
|
{
|
|
name: "ech unsupported",
|
|
options: option.HTTPClientOptions{
|
|
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
|
|
TLS: &option.OutboundTLSOptions{
|
|
ECH: &option.OutboundECHOptions{Enabled: true},
|
|
},
|
|
},
|
|
},
|
|
wantErr: "ech is unsupported in Apple HTTP engine",
|
|
},
|
|
{
|
|
name: "utls unsupported",
|
|
options: option.HTTPClientOptions{
|
|
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
|
|
TLS: &option.OutboundTLSOptions{
|
|
UTLS: &option.OutboundUTLSOptions{Enabled: true},
|
|
},
|
|
},
|
|
},
|
|
wantErr: "utls is unsupported in Apple HTTP engine",
|
|
},
|
|
{
|
|
name: "reality unsupported",
|
|
options: option.HTTPClientOptions{
|
|
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
|
|
TLS: &option.OutboundTLSOptions{
|
|
Reality: &option.OutboundRealityOptions{Enabled: true},
|
|
},
|
|
},
|
|
},
|
|
wantErr: "reality is unsupported in Apple HTTP engine",
|
|
},
|
|
{
|
|
name: "pin and certificate conflict",
|
|
options: option.HTTPClientOptions{
|
|
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
|
|
TLS: &option.OutboundTLSOptions{
|
|
Certificate: badoption.Listable[string]{serverCertificatePEM},
|
|
CertificatePublicKeySHA256: badoption.Listable[[]byte]{serverHash},
|
|
},
|
|
},
|
|
},
|
|
wantErr: "certificate_public_key_sha256 is conflict with certificate or certificate_path",
|
|
},
|
|
{
|
|
name: "invalid min version",
|
|
options: option.HTTPClientOptions{
|
|
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
|
|
TLS: &option.OutboundTLSOptions{MinVersion: "bogus"},
|
|
},
|
|
},
|
|
wantErr: "parse min_version",
|
|
},
|
|
{
|
|
name: "invalid max version",
|
|
options: option.HTTPClientOptions{
|
|
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
|
|
TLS: &option.OutboundTLSOptions{MaxVersion: "bogus"},
|
|
},
|
|
},
|
|
wantErr: "parse max_version",
|
|
},
|
|
{
|
|
name: "invalid pin length",
|
|
options: option.HTTPClientOptions{
|
|
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
|
|
TLS: &option.OutboundTLSOptions{
|
|
CertificatePublicKeySHA256: badoption.Listable[[]byte]{{0x01, 0x02}},
|
|
},
|
|
},
|
|
},
|
|
wantErr: "invalid certificate_public_key_sha256 length: 2",
|
|
},
|
|
}
|
|
|
|
for _, testCase := range testCases {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
config, err := newAppleSessionConfig(context.Background(), testCase.options)
|
|
if testCase.wantErr != "" {
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if !strings.Contains(err.Error(), testCase.wantErr) {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if testCase.check != nil {
|
|
testCase.check(t, config)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAppleTransportVerifyPublicKeySHA256(t *testing.T) {
|
|
serverCertificate, _ := newAppleHTTPTestCertificate(t, "localhost")
|
|
goodHash := certificatePublicKeySHA256(t, serverCertificate.Certificate[0])
|
|
badHash := append([]byte(nil), goodHash...)
|
|
badHash[0] ^= 0xff
|
|
|
|
err := verifyApplePinnedPublicKeySHA256(goodHash, serverCertificate.Certificate[0])
|
|
if err != nil {
|
|
t.Fatalf("expected correct pin to succeed: %v", err)
|
|
}
|
|
|
|
err = verifyApplePinnedPublicKeySHA256(badHash, serverCertificate.Certificate[0])
|
|
if err == nil {
|
|
t.Fatal("expected incorrect pin to fail")
|
|
}
|
|
if !strings.Contains(err.Error(), "unrecognized remote public key") {
|
|
t.Fatalf("unexpected pin mismatch error: %v", err)
|
|
}
|
|
|
|
err = verifyApplePinnedPublicKeySHA256(goodHash[:applePinnedHashSize-1], serverCertificate.Certificate[0])
|
|
if err == nil {
|
|
t.Fatal("expected malformed pin list to fail")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid pinned public key list") {
|
|
t.Fatalf("unexpected malformed pin error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNewAppleTransportClosesSessionConfigOnBridgeFailure(t *testing.T) {
|
|
_, serverCertificatePEM := newAppleHTTPTestCertificate(t, "localhost")
|
|
restoreAppleTransportFactories(t)
|
|
testAnchors := &appleTestAnchors{ref: unsafe.Pointer(new(int))}
|
|
newAppleUserAnchors = func([]byte) (adapter.AppleAnchors, error) {
|
|
return testAnchors, nil
|
|
}
|
|
newAppleProxyBridge = func(context.Context, commonLogger.ContextLogger, string, N.Dialer) (*proxybridge.Bridge, error) {
|
|
return nil, errors.New("bridge boom")
|
|
}
|
|
|
|
_, err := newAppleTransport(newAppleHTTPTestContext(), log.NewNOPFactory().NewLogger("httpclient"), &appleHTTPTestDialer{}, appleTransportAnchorOptions(serverCertificatePEM))
|
|
if err == nil || !strings.Contains(err.Error(), "bridge boom") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if testAnchors.releases != 1 {
|
|
t.Fatalf("expected 1 anchor release, got %d", testAnchors.releases)
|
|
}
|
|
}
|
|
|
|
func TestNewAppleTransportClosesSessionConfigOnSessionFailure(t *testing.T) {
|
|
_, serverCertificatePEM := newAppleHTTPTestCertificate(t, "localhost")
|
|
restoreAppleTransportFactories(t)
|
|
testAnchors := &appleTestAnchors{ref: unsafe.Pointer(new(int))}
|
|
newAppleUserAnchors = func([]byte) (adapter.AppleAnchors, error) {
|
|
return testAnchors, nil
|
|
}
|
|
newAppleTransportSession = func(*appleTransportShared) (unsafe.Pointer, error) {
|
|
return nil, errors.New("session boom")
|
|
}
|
|
|
|
_, err := newAppleTransport(newAppleHTTPTestContext(), log.NewNOPFactory().NewLogger("httpclient"), &appleHTTPTestDialer{}, appleTransportAnchorOptions(serverCertificatePEM))
|
|
if err == nil || !strings.Contains(err.Error(), "session boom") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if testAnchors.releases != 1 {
|
|
t.Fatalf("expected 1 anchor release, got %d", testAnchors.releases)
|
|
}
|
|
}
|
|
|
|
func TestAppleTransportRoundTripHTTPS(t *testing.T) {
|
|
requests := make(chan appleHTTPObservedRequest, 1)
|
|
server := startAppleHTTPTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
t.Error(err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
requests <- appleHTTPObservedRequest{
|
|
method: r.Method,
|
|
body: string(body),
|
|
host: r.Host,
|
|
values: append([]string(nil), r.Header.Values("X-Test")...),
|
|
protoMajor: r.ProtoMajor,
|
|
}
|
|
w.Header().Set("X-Reply", "apple")
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = w.Write([]byte("response body"))
|
|
})
|
|
|
|
transport := newAppleHTTPTestTransport(t, server, option.HTTPClientOptions{
|
|
Version: 2,
|
|
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
|
|
TLS: appleHTTPServerTLSOptions(server),
|
|
},
|
|
})
|
|
|
|
request, err := http.NewRequest(http.MethodPost, server.URL("/roundtrip"), bytes.NewReader([]byte("request body")))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request.Header.Add("X-Test", "one")
|
|
request.Header.Add("X-Test", "two")
|
|
request.Host = "custom.example"
|
|
|
|
response, err := transport.RoundTrip(request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
responseBody := readResponseBody(t, response)
|
|
if response.StatusCode != http.StatusCreated {
|
|
t.Fatalf("unexpected status code: %d", response.StatusCode)
|
|
}
|
|
if response.Status != "201 Created" {
|
|
t.Fatalf("unexpected status: %q", response.Status)
|
|
}
|
|
if response.Header.Get("X-Reply") != "apple" {
|
|
t.Fatalf("unexpected response header: %q", response.Header.Get("X-Reply"))
|
|
}
|
|
if responseBody != "response body" {
|
|
t.Fatalf("unexpected response body: %q", responseBody)
|
|
}
|
|
if response.ContentLength != int64(len(responseBody)) {
|
|
t.Fatalf("unexpected content length: %d", response.ContentLength)
|
|
}
|
|
|
|
observed := waitObservedRequest(t, requests)
|
|
if observed.method != http.MethodPost {
|
|
t.Fatalf("unexpected method: %q", observed.method)
|
|
}
|
|
if observed.body != "request body" {
|
|
t.Fatalf("unexpected request body: %q", observed.body)
|
|
}
|
|
if observed.host != "custom.example" {
|
|
t.Fatalf("unexpected host: %q", observed.host)
|
|
}
|
|
if observed.protoMajor != 2 {
|
|
t.Fatalf("expected HTTP/2 request, got HTTP/%d", observed.protoMajor)
|
|
}
|
|
var normalizedValues []string
|
|
for _, value := range observed.values {
|
|
for part := range strings.SplitSeq(value, ",") {
|
|
normalizedValues = append(normalizedValues, strings.TrimSpace(part))
|
|
}
|
|
}
|
|
slices.Sort(normalizedValues)
|
|
if !slices.Equal(normalizedValues, []string{"one", "two"}) {
|
|
t.Fatalf("unexpected header values: %#v", observed.values)
|
|
}
|
|
}
|
|
|
|
func TestAppleTransportPinnedPublicKey(t *testing.T) {
|
|
server := startAppleHTTPTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("pinned"))
|
|
})
|
|
|
|
goodTransport := newAppleHTTPTestTransport(t, server, option.HTTPClientOptions{
|
|
Version: 2,
|
|
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
|
|
TLS: &option.OutboundTLSOptions{
|
|
Enabled: true,
|
|
ServerName: "localhost",
|
|
Insecure: true,
|
|
CertificatePublicKeySHA256: badoption.Listable[[]byte]{server.publicKeyHash},
|
|
},
|
|
},
|
|
})
|
|
|
|
response, err := goodTransport.RoundTrip(newAppleHTTPRequest(t, http.MethodGet, server.URL("/good"), nil))
|
|
if err != nil {
|
|
t.Fatalf("expected pinned request to succeed: %v", err)
|
|
}
|
|
response.Body.Close()
|
|
|
|
badHash := append([]byte(nil), server.publicKeyHash...)
|
|
badHash[0] ^= 0xff
|
|
badTransport := newAppleHTTPTestTransport(t, server, option.HTTPClientOptions{
|
|
Version: 2,
|
|
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
|
|
TLS: &option.OutboundTLSOptions{
|
|
Enabled: true,
|
|
ServerName: "localhost",
|
|
Insecure: true,
|
|
CertificatePublicKeySHA256: badoption.Listable[[]byte]{badHash},
|
|
},
|
|
},
|
|
})
|
|
|
|
response, err = badTransport.RoundTrip(newAppleHTTPRequest(t, http.MethodGet, server.URL("/bad"), nil))
|
|
if err == nil {
|
|
response.Body.Close()
|
|
t.Fatal("expected incorrect pinned public key to fail")
|
|
}
|
|
}
|
|
|
|
func TestAppleTransportGuardrails(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
options option.HTTPClientOptions
|
|
buildRequest func(t *testing.T) *http.Request
|
|
wantErrSubstr string
|
|
}{
|
|
{
|
|
name: "websocket upgrade rejected",
|
|
options: option.HTTPClientOptions{
|
|
Version: 2,
|
|
},
|
|
buildRequest: func(t *testing.T) *http.Request {
|
|
t.Helper()
|
|
request := newAppleHTTPRequest(t, http.MethodGet, "https://localhost/socket", nil)
|
|
request.Header.Set("Connection", "Upgrade")
|
|
request.Header.Set("Upgrade", "websocket")
|
|
return request
|
|
},
|
|
wantErrSubstr: "HTTP upgrade requests are unsupported in Apple HTTP engine",
|
|
},
|
|
{
|
|
name: "missing url rejected",
|
|
options: option.HTTPClientOptions{
|
|
Version: 2,
|
|
},
|
|
buildRequest: func(t *testing.T) *http.Request {
|
|
t.Helper()
|
|
return &http.Request{Method: http.MethodGet}
|
|
},
|
|
wantErrSubstr: "missing request URL",
|
|
},
|
|
{
|
|
name: "unsupported scheme rejected",
|
|
options: option.HTTPClientOptions{
|
|
Version: 2,
|
|
},
|
|
buildRequest: func(t *testing.T) *http.Request {
|
|
t.Helper()
|
|
return newAppleHTTPRequest(t, http.MethodGet, "ftp://localhost/file", nil)
|
|
},
|
|
wantErrSubstr: "unsupported URL scheme: ftp",
|
|
},
|
|
{
|
|
name: "server name mismatch rejected",
|
|
options: option.HTTPClientOptions{
|
|
Version: 2,
|
|
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
|
|
TLS: &option.OutboundTLSOptions{
|
|
Enabled: true,
|
|
ServerName: "example.com",
|
|
},
|
|
},
|
|
},
|
|
buildRequest: func(t *testing.T) *http.Request {
|
|
t.Helper()
|
|
return newAppleHTTPRequest(t, http.MethodGet, "https://localhost/path", nil)
|
|
},
|
|
wantErrSubstr: "tls.server_name is unsupported in Apple HTTP engine unless it matches request host",
|
|
},
|
|
}
|
|
|
|
for _, testCase := range testCases {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
transport := newAppleHTTPTestTransport(t, nil, testCase.options)
|
|
response, err := transport.RoundTrip(testCase.buildRequest(t))
|
|
if err == nil {
|
|
response.Body.Close()
|
|
t.Fatal("expected error")
|
|
}
|
|
if !strings.Contains(err.Error(), testCase.wantErrSubstr) {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAppleTransportCancellationRecovery(t *testing.T) {
|
|
server := startAppleHTTPTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/block":
|
|
select {
|
|
case <-r.Context().Done():
|
|
return
|
|
case <-time.After(appleHTTPTestTimeout):
|
|
http.Error(w, "request was not canceled", http.StatusGatewayTimeout)
|
|
}
|
|
default:
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ok"))
|
|
}
|
|
})
|
|
|
|
transport := newAppleHTTPTestTransport(t, server, option.HTTPClientOptions{
|
|
Version: 2,
|
|
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
|
|
TLS: appleHTTPServerTLSOptions(server),
|
|
},
|
|
})
|
|
|
|
for index := range appleHTTPRecoveryLoops {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
request := newAppleHTTPRequestWithContext(t, ctx, http.MethodGet, server.URL("/block"), nil)
|
|
response, err := transport.RoundTrip(request)
|
|
cancel()
|
|
if err == nil {
|
|
response.Body.Close()
|
|
t.Fatalf("iteration %d: expected cancellation error", index)
|
|
}
|
|
if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("iteration %d: unexpected cancellation error: %v", index, err)
|
|
}
|
|
|
|
response, err = transport.RoundTrip(newAppleHTTPRequest(t, http.MethodGet, server.URL("/ok"), nil))
|
|
if err != nil {
|
|
t.Fatalf("iteration %d: follow-up request failed: %v", index, err)
|
|
}
|
|
if body := readResponseBody(t, response); body != "ok" {
|
|
response.Body.Close()
|
|
t.Fatalf("iteration %d: unexpected follow-up body: %q", index, body)
|
|
}
|
|
response.Body.Close()
|
|
}
|
|
}
|
|
|
|
func TestAppleTransportLifecycle(t *testing.T) {
|
|
server := startAppleHTTPTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ok"))
|
|
})
|
|
|
|
transport := newAppleHTTPTestTransport(t, server, option.HTTPClientOptions{
|
|
Version: 2,
|
|
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
|
|
TLS: appleHTTPServerTLSOptions(server),
|
|
},
|
|
})
|
|
|
|
assertAppleHTTPSucceeds(t, transport, server.URL("/original"))
|
|
|
|
transport.CloseIdleConnections()
|
|
assertAppleHTTPSucceeds(t, transport, server.URL("/reset"))
|
|
|
|
innerTransport := transport.(*appleTransport)
|
|
err := innerTransport.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
response, err := innerTransport.RoundTrip(newAppleHTTPRequest(t, http.MethodGet, server.URL("/closed"), nil))
|
|
if err == nil {
|
|
response.Body.Close()
|
|
t.Fatal("expected closed transport to fail")
|
|
}
|
|
if !errors.Is(err, net.ErrClosed) {
|
|
t.Fatalf("unexpected closed transport error: %v", err)
|
|
}
|
|
}
|
|
|
|
func startAppleHTTPTestServer(t *testing.T, handler http.HandlerFunc) *appleHTTPTestServer {
|
|
t.Helper()
|
|
|
|
serverCertificate, serverCertificatePEM := newAppleHTTPTestCertificate(t, "localhost")
|
|
server := httptest.NewUnstartedServer(handler)
|
|
server.EnableHTTP2 = true
|
|
server.TLS = &stdtls.Config{
|
|
Certificates: []stdtls.Certificate{serverCertificate},
|
|
MinVersion: stdtls.VersionTLS12,
|
|
}
|
|
server.StartTLS()
|
|
t.Cleanup(server.Close)
|
|
|
|
parsedURL, err := url.Parse(server.URL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
baseURL := *parsedURL
|
|
baseURL.Host = net.JoinHostPort("localhost", parsedURL.Port())
|
|
|
|
return &appleHTTPTestServer{
|
|
server: server,
|
|
baseURL: baseURL.String(),
|
|
dialHost: parsedURL.Hostname(),
|
|
certificate: serverCertificate,
|
|
certificatePEM: serverCertificatePEM,
|
|
publicKeyHash: certificatePublicKeySHA256(t, serverCertificate.Certificate[0]),
|
|
}
|
|
}
|
|
|
|
func (s *appleHTTPTestServer) URL(path string) string {
|
|
if path == "" {
|
|
return s.baseURL
|
|
}
|
|
if strings.HasPrefix(path, "/") {
|
|
return s.baseURL + path
|
|
}
|
|
return s.baseURL + "/" + path
|
|
}
|
|
|
|
func newAppleHTTPTestTransport(t *testing.T, server *appleHTTPTestServer, options option.HTTPClientOptions) innerTransport {
|
|
t.Helper()
|
|
|
|
ctx := newAppleHTTPTestContext()
|
|
dialer := &appleHTTPTestDialer{
|
|
hostMap: make(map[string]string),
|
|
}
|
|
if server != nil {
|
|
dialer.hostMap["localhost"] = server.dialHost
|
|
}
|
|
|
|
transport, err := newAppleTransport(ctx, log.NewNOPFactory().NewLogger("httpclient"), dialer, options)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_ = transport.Close()
|
|
})
|
|
return transport
|
|
}
|
|
|
|
func newAppleHTTPTestContext() context.Context {
|
|
return service.ContextWith[adapter.ConnectionManager](
|
|
context.Background(),
|
|
route.NewConnectionManager(log.NewNOPFactory().NewLogger("connection")),
|
|
)
|
|
}
|
|
|
|
func appleTransportAnchorOptions(certificatePEM string) option.HTTPClientOptions {
|
|
return option.HTTPClientOptions{
|
|
Version: 2,
|
|
OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{
|
|
TLS: &option.OutboundTLSOptions{
|
|
Enabled: true,
|
|
ServerName: "localhost",
|
|
MinVersion: "1.2",
|
|
Certificate: badoption.Listable[string]{certificatePEM},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func restoreAppleTransportFactories(t *testing.T) {
|
|
t.Helper()
|
|
oldAnchors := newAppleUserAnchors
|
|
oldBridge := newAppleProxyBridge
|
|
oldSession := newAppleTransportSession
|
|
t.Cleanup(func() {
|
|
newAppleUserAnchors = oldAnchors
|
|
newAppleProxyBridge = oldBridge
|
|
newAppleTransportSession = oldSession
|
|
})
|
|
}
|
|
|
|
func (d *appleHTTPTestDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
|
|
host := destination.AddrString()
|
|
if destination.IsDomain() {
|
|
host = destination.Fqdn
|
|
if mappedHost, loaded := d.hostMap[host]; loaded {
|
|
host = mappedHost
|
|
}
|
|
}
|
|
return d.dialer.DialContext(ctx, network, net.JoinHostPort(host, strconv.Itoa(int(destination.Port))))
|
|
}
|
|
|
|
func (d *appleHTTPTestDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
|
|
host := destination.AddrString()
|
|
if destination.IsDomain() {
|
|
host = destination.Fqdn
|
|
if mappedHost, loaded := d.hostMap[host]; loaded {
|
|
host = mappedHost
|
|
}
|
|
}
|
|
if host == "" {
|
|
host = "127.0.0.1"
|
|
}
|
|
return d.listener.ListenPacket(ctx, N.NetworkUDP, net.JoinHostPort(host, strconv.Itoa(int(destination.Port))))
|
|
}
|
|
|
|
func newAppleHTTPTestCertificate(t *testing.T, serverName string) (stdtls.Certificate, string) {
|
|
t.Helper()
|
|
|
|
privateKeyPEM, certificatePEM, err := boxTLS.GenerateCertificate(nil, nil, time.Now, serverName, time.Now().Add(time.Hour))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
certificate, err := stdtls.X509KeyPair(certificatePEM, privateKeyPEM)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return certificate, string(certificatePEM)
|
|
}
|
|
|
|
func certificatePublicKeySHA256(t *testing.T, certificateDER []byte) []byte {
|
|
t.Helper()
|
|
|
|
certificate, err := x509.ParseCertificate(certificateDER)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
publicKeyDER, err := x509.MarshalPKIXPublicKey(certificate.PublicKey)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hashValue := sha256.Sum256(publicKeyDER)
|
|
return append([]byte(nil), hashValue[:]...)
|
|
}
|
|
|
|
func appleHTTPServerTLSOptions(server *appleHTTPTestServer) *option.OutboundTLSOptions {
|
|
return &option.OutboundTLSOptions{
|
|
Enabled: true,
|
|
ServerName: "localhost",
|
|
Certificate: badoption.Listable[string]{server.certificatePEM},
|
|
}
|
|
}
|
|
|
|
func newAppleHTTPRequest(t *testing.T, method string, rawURL string, body []byte) *http.Request {
|
|
t.Helper()
|
|
return newAppleHTTPRequestWithContext(t, context.Background(), method, rawURL, body)
|
|
}
|
|
|
|
func newAppleHTTPRequestWithContext(t *testing.T, ctx context.Context, method string, rawURL string, body []byte) *http.Request {
|
|
t.Helper()
|
|
request, err := http.NewRequestWithContext(ctx, method, rawURL, bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return request
|
|
}
|
|
|
|
func waitObservedRequest(t *testing.T, requests <-chan appleHTTPObservedRequest) appleHTTPObservedRequest {
|
|
t.Helper()
|
|
|
|
select {
|
|
case request := <-requests:
|
|
return request
|
|
case <-time.After(appleHTTPTestTimeout):
|
|
t.Fatal("timed out waiting for observed request")
|
|
return appleHTTPObservedRequest{}
|
|
}
|
|
}
|
|
|
|
func readResponseBody(t *testing.T, response *http.Response) string {
|
|
t.Helper()
|
|
|
|
body, err := io.ReadAll(response.Body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(body)
|
|
}
|
|
|
|
func assertAppleHTTPSucceeds(t *testing.T, transport http.RoundTripper, rawURL string) {
|
|
t.Helper()
|
|
|
|
response, err := transport.RoundTrip(newAppleHTTPRequest(t, http.MethodGet, rawURL, nil))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer response.Body.Close()
|
|
if body := readResponseBody(t, response); body != "ok" {
|
|
t.Fatalf("unexpected response body: %q", body)
|
|
}
|
|
}
|