diff --git a/backend/backend.go b/backend/backend.go index 4bb9854ac6..16f3f4f5c1 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -47,6 +47,7 @@ import ( "github.com/BitBoxSwiss/bitbox-wallet-app/util/observable" "github.com/BitBoxSwiss/bitbox-wallet-app/util/observable/action" "github.com/BitBoxSwiss/bitbox-wallet-app/util/socksproxy" + "github.com/BitBoxSwiss/bitbox-wallet-app/util/useragent" "github.com/btcsuite/btcd/chaincfg" "github.com/ethereum/go-ethereum/params" "github.com/sirupsen/logrus" @@ -198,6 +199,8 @@ type Environment interface { // BluetoothConnect tries to connect to the peripheral by the given identifier. // Use `backend.bluetooth.State()` to track failure. BluetoothConnect(identifier string) + // UserAgentHost returns the host platform/device token used in the app's outbound user agent. + UserAgentHost() string } // Backend ties everything together and is the main starting point to use the BitBox wallet library. @@ -287,6 +290,13 @@ func NewBackend(arguments *arguments.Arguments, environment Environment) (*Backe useProxy, backendConfig.AppConfig().Backend.Proxy.ProxyAddress, ) + userAgentHost := useragent.HostFromRuntime() + if environment != nil { + if host := environment.UserAgentHost(); host != "" { + userAgentHost = host + } + } + backendProxy = backendProxy.WithUserAgent(useragent.String(versioninfo.Version.String(), userAgentHost)) hclient, err := backendProxy.GetHTTPClient() if err != nil { return nil, err diff --git a/backend/backend_test.go b/backend/backend_test.go index 983af5227e..301f98cbe0 100644 --- a/backend/backend_test.go +++ b/backend/backend_test.go @@ -247,6 +247,10 @@ func (e environment) OnAuthSettingChanged(bool) {} func (e environment) BluetoothConnect(string) {} +func (e environment) UserAgentHost() string { + return "linux" +} + type mockTransactionsSource struct { } diff --git a/backend/bridgecommon/bridgecommon.go b/backend/bridgecommon/bridgecommon.go index c99dbb9f87..cb78c2df2f 100644 --- a/backend/bridgecommon/bridgecommon.go +++ b/backend/bridgecommon/bridgecommon.go @@ -187,6 +187,7 @@ type BackendEnvironment struct { AuthFunc func() OnAuthSettingChangedFunc func(bool) BluetoothConnectFunc func(string) + UserAgentHostFunc func() string } // NotifyUser implements backend.Environment. @@ -272,6 +273,14 @@ func (env *BackendEnvironment) BluetoothConnect(identifier string) { } } +// UserAgentHost implements backend.Environment. +func (env *BackendEnvironment) UserAgentHost() string { + if env.UserAgentHostFunc != nil { + return env.UserAgentHostFunc() + } + return "" +} + // Serve serves the BitBox API for use in a native client. func Serve( testnet bool, diff --git a/backend/bridgecommon/bridgecommon_test.go b/backend/bridgecommon/bridgecommon_test.go index 296ada0464..39da729186 100644 --- a/backend/bridgecommon/bridgecommon_test.go +++ b/backend/bridgecommon/bridgecommon_test.go @@ -63,6 +63,10 @@ func (e environment) OnAuthSettingChanged(bool) {} func (e environment) BluetoothConnect(string) {} +func (e environment) UserAgentHost() string { + return "linux" +} + // TestServeShutdownServe checks that you can call Serve twice in a row. func TestServeShutdownServe(t *testing.T) { bridgecommon.Serve( diff --git a/backend/handlers/handlers_test.go b/backend/handlers/handlers_test.go index fe22e4dd1d..55a0ccea74 100644 --- a/backend/handlers/handlers_test.go +++ b/backend/handlers/handlers_test.go @@ -49,6 +49,7 @@ func (e *backendEnv) DetectDarkTheme() bool { return false } func (e *backendEnv) Auth() {} func (e *backendEnv) OnAuthSettingChanged(bool) {} func (e *backendEnv) BluetoothConnect(string) {} +func (e *backendEnv) UserAgentHost() string { return "linux" } func TestGetNativeLocale(t *testing.T) { const ptLocale = "pt" diff --git a/backend/mobileserver/mobileserver.go b/backend/mobileserver/mobileserver.go index 4d70086166..ec0190ac48 100644 --- a/backend/mobileserver/mobileserver.go +++ b/backend/mobileserver/mobileserver.go @@ -75,6 +75,7 @@ type GoEnvironmentInterface interface { Auth() OnAuthSettingChanged(bool) BluetoothConnect(string) + UserAgentHost() string } // readWriteCloser implements io.ReadWriteCloser, translating from GoReadWriteCloserInterface. All methods @@ -194,6 +195,7 @@ func Serve(dataDir string, testnet bool, environment GoEnvironmentInterface, goA AuthFunc: environment.Auth, OnAuthSettingChangedFunc: environment.OnAuthSettingChanged, BluetoothConnectFunc: environment.BluetoothConnect, + UserAgentHostFunc: environment.UserAgentHost, }, ) } diff --git a/cmd/servewallet/main.go b/cmd/servewallet/main.go index c4e9cbaf5d..1c5c67c7db 100644 --- a/cmd/servewallet/main.go +++ b/cmd/servewallet/main.go @@ -21,6 +21,7 @@ import ( "github.com/BitBoxSwiss/bitbox-wallet-app/util/config" "github.com/BitBoxSwiss/bitbox-wallet-app/util/logging" "github.com/BitBoxSwiss/bitbox-wallet-app/util/system" + "github.com/BitBoxSwiss/bitbox-wallet-app/util/useragent" "github.com/sirupsen/logrus" ) @@ -96,6 +97,11 @@ func (webdevEnvironment) OnAuthSettingChanged(enabled bool) { func (webdevEnvironment) BluetoothConnect(identifier string) { } +// UserAgentHost implements backend.Environment. +func (webdevEnvironment) UserAgentHost() string { + return useragent.HostFromRuntime() +} + // NativeLocale naively implements backend.Environment. // This version is unlikely to work on Windows. func (webdevEnvironment) NativeLocale() string { diff --git a/frontends/android/BitBoxApp/app/src/main/java/ch/shiftcrypto/bitboxapp/GoViewModel.java b/frontends/android/BitBoxApp/app/src/main/java/ch/shiftcrypto/bitboxapp/GoViewModel.java index b46a7b7d99..9a4a93611e 100644 --- a/frontends/android/BitBoxApp/app/src/main/java/ch/shiftcrypto/bitboxapp/GoViewModel.java +++ b/frontends/android/BitBoxApp/app/src/main/java/ch/shiftcrypto/bitboxapp/GoViewModel.java @@ -187,6 +187,10 @@ public String nativeLocale() { return locale.toString(); } + public String userAgentHost() { + return "android"; + } + @Override public void setDarkTheme(boolean isDark) { Util.log("Set Dark Theme GoViewModel - isdark: " + isDark); diff --git a/frontends/ios/BitBoxApp/BitBoxApp/BitBoxAppApp.swift b/frontends/ios/BitBoxApp/BitBoxApp/BitBoxAppApp.swift index b929cda9fc..34d9d1b2dc 100644 --- a/frontends/ios/BitBoxApp/BitBoxApp/BitBoxAppApp.swift +++ b/frontends/ios/BitBoxApp/BitBoxApp/BitBoxAppApp.swift @@ -6,6 +6,7 @@ // import SwiftUI +import UIKit import Mobileserver import LocalAuthentication import Network @@ -84,6 +85,10 @@ class GoEnvironment: NSObject, MobileserverGoEnvironmentInterfaceProtocol, UIDoc return Locale.current.identifier } + func userAgentHost() -> String { + return UIDevice.current.userInterfaceIdiom == .pad ? "ipad" : "iphone" + } + func notifyUser(_ p0: String?) { } diff --git a/frontends/qt/server/server.go b/frontends/qt/server/server.go index 1913e69d6e..c4b0e21d1d 100644 --- a/frontends/qt/server/server.go +++ b/frontends/qt/server/server.go @@ -61,6 +61,7 @@ import ( "github.com/BitBoxSwiss/bitbox-wallet-app/backend/mobileserver" "github.com/BitBoxSwiss/bitbox-wallet-app/util/logging" "github.com/BitBoxSwiss/bitbox-wallet-app/util/system" + "github.com/BitBoxSwiss/bitbox-wallet-app/util/useragent" ) // nativeCommunication implements bridge.NativeCommunication. @@ -198,6 +199,7 @@ func serve( }, OnAuthSettingChangedFunc: func(bool) {}, BluetoothConnectFunc: func(string) {}, + UserAgentHostFunc: useragent.HostFromRuntime, }, ) } diff --git a/util/socksproxy/socksproxy.go b/util/socksproxy/socksproxy.go index 14c77a6360..7f51b574ad 100644 --- a/util/socksproxy/socksproxy.go +++ b/util/socksproxy/socksproxy.go @@ -8,6 +8,7 @@ import ( "net/url" "github.com/BitBoxSwiss/bitbox-wallet-app/util/logging" + "github.com/BitBoxSwiss/bitbox-wallet-app/util/useragent" "github.com/sirupsen/logrus" "golang.org/x/net/proxy" ) @@ -17,6 +18,7 @@ type SocksProxy struct { useProxy bool proxyAddress string fullProxyAddress string + userAgent string log *logrus.Entry } @@ -37,6 +39,12 @@ func NewSocksProxy(useProxy bool, proxyAddress string) SocksProxy { return proxy } +// WithUserAgent configures the user agent for BitBox/Shift-owned HTTP requests. +func (socksProxy SocksProxy) WithUserAgent(ua string) SocksProxy { + socksProxy.userAgent = ua + return socksProxy +} + // Validate validates the socks5 proxy endpoint. // We check if we could instantiate a proxied http client. // Currently, no actual connectivity checks as performed. @@ -88,8 +96,16 @@ func (socksProxy *SocksProxy) GetHTTPClient() (*http.Client, error) { // Make a http.Transport that uses the proxy dialer, and a // http.Client that uses the transport. tbTransport := &http.Transport{Dial: tbDialer.Dial} - client := &http.Client{Transport: tbTransport} - return client, nil + return socksProxy.httpClient(tbTransport), nil + } + return socksProxy.httpClient(nil), nil +} + +func (socksProxy *SocksProxy) httpClient(transport http.RoundTripper) *http.Client { + if socksProxy.userAgent == "" { + return &http.Client{Transport: transport} + } + return &http.Client{ + Transport: useragent.NewTransport(transport, socksProxy.userAgent), } - return &http.Client{}, nil } diff --git a/util/socksproxy/socksproxy_test.go b/util/socksproxy/socksproxy_test.go index eb0ecf28e5..56ab3fca6f 100644 --- a/util/socksproxy/socksproxy_test.go +++ b/util/socksproxy/socksproxy_test.go @@ -3,6 +3,9 @@ package socksproxy import ( + "io" + "net/http" + "strings" "testing" "github.com/stretchr/testify/require" @@ -16,3 +19,26 @@ func TestValidate(t *testing.T) { require.Error(t, NewSocksProxy(true, "127.0.0.1:XXXX").Validate()) require.Error(t, NewSocksProxy(true, "127.0.0.1:9050 ").Validate()) } + +func TestGetHTTPClientUserAgent(t *testing.T) { + const ua = "BitBoxApp/1.2.3 (linux)" + + socksProxy := NewSocksProxy(false, "").WithUserAgent(ua) + client := socksProxy.httpClient(roundTripFunc(func(req *http.Request) (*http.Response, error) { + require.Equal(t, ua, req.Header.Get("User-Agent")) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("{}")), + Header: http.Header{}, + }, nil + })) + + _, err := client.Get("https://bitboxapp.shiftcrypto.io/banners.json") + require.NoError(t, err) +} + +type roundTripFunc func(req *http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} diff --git a/util/useragent/useragent.go b/util/useragent/useragent.go new file mode 100644 index 0000000000..79da5d68b8 --- /dev/null +++ b/util/useragent/useragent.go @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 + +package useragent + +import ( + "net/http" + "runtime" + "strings" +) + +var ownedHostSuffixes = []string{ + ".shiftcrypto.io", + ".shiftcrypto.dev", + ".bitbox.swiss", +} + +// String returns the BitBoxApp user agent. +func String(version, host string) string { + return "BitBoxApp/" + version + " (" + host + ")" +} + +// HostFromRuntime returns the user agent host token for the current Go runtime. +func HostFromRuntime() string { + switch runtime.GOOS { + case "darwin": + return "mac" + case "windows": + return "win" + default: + return runtime.GOOS + } +} + +// IsOwnedHost returns true if host belongs to BitBox/Shift infrastructure. +func IsOwnedHost(host string) bool { + host = strings.ToLower(strings.TrimSuffix(host, ".")) + if host == "digitalbitbox.com" { + return true + } + for _, suffix := range ownedHostSuffixes { + if strings.HasSuffix(host, suffix) { + return true + } + } + return false +} + +type transport struct { + base http.RoundTripper + userAgent string +} + +// NewTransport returns a transport that adds the user agent only to BitBox/Shift-owned hosts. +func NewTransport(base http.RoundTripper, userAgent string) http.RoundTripper { + if base == nil { + base = http.DefaultTransport + } + return &transport{ + base: base, + userAgent: userAgent, + } +} + +func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) { + if req.Header.Get("User-Agent") != "" || !IsOwnedHost(req.URL.Hostname()) { + return t.base.RoundTrip(req) + } + + cloned := req.Clone(req.Context()) + cloned.Header = req.Header.Clone() + cloned.Header.Set("User-Agent", t.userAgent) + return t.base.RoundTrip(cloned) +} diff --git a/util/useragent/useragent_test.go b/util/useragent/useragent_test.go new file mode 100644 index 0000000000..2e8c9d46e3 --- /dev/null +++ b/util/useragent/useragent_test.go @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: Apache-2.0 + +package useragent + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +type roundTripFunc func(req *http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestString(t *testing.T) { + require.Equal(t, "BitBoxApp/1.2.3 (linux)", String("1.2.3", "linux")) +} + +func TestIsOwnedHost(t *testing.T) { + tests := []struct { + name string + host string + want bool + }{ + {name: "shiftcrypto io", host: "swapkit.shiftcrypto.io", want: true}, + {name: "shiftcrypto dev", host: "bitboxapp.shiftcrypto.dev", want: true}, + {name: "bitbox swiss", host: "shop.bitbox.swiss", want: true}, + {name: "digitalbitbox", host: "digitalbitbox.com", want: true}, + {name: "case insensitive", host: "FEES1.SHIFTCRYPTO.IO", want: true}, + {name: "trailing dot", host: "fees1.shiftcrypto.io.", want: true}, + {name: "not suffix confusion", host: "evilshiftcrypto.io", want: false}, + {name: "moonpay", host: "api.moonpay.com", want: false}, + {name: "etherscan", host: "api.etherscan.io", want: false}, + {name: "coingecko", host: "api.coingecko.com", want: false}, + {name: "bitsurance", host: "get.bitsurance.eu", want: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.want, IsOwnedHost(test.host)) + }) + } +} + +func TestTransportSetsUserAgentOnlyForOwnedHosts(t *testing.T) { + const userAgent = "BitBoxApp/1.2.3 (linux)" + seenUserAgents := map[string]string{} + client := &http.Client{ + Transport: NewTransport(roundTripFunc(func(req *http.Request) (*http.Response, error) { + seenUserAgents[req.URL.Hostname()] = req.Header.Get("User-Agent") + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("{}")), + Header: http.Header{}, + }, nil + }), userAgent), + } + + _, err := client.Get("https://swapkit.shiftcrypto.io/v3/quote") + require.NoError(t, err) + _, err = client.Get("https://api.moonpay.com/v1/regions") + require.NoError(t, err) + + require.Equal(t, userAgent, seenUserAgents["swapkit.shiftcrypto.io"]) + require.Empty(t, seenUserAgents["api.moonpay.com"]) +} + +func TestTransportPreservesExistingUserAgent(t *testing.T) { + client := &http.Client{ + Transport: NewTransport(roundTripFunc(func(req *http.Request) (*http.Response, error) { + require.Equal(t, "custom-agent", req.Header.Get("User-Agent")) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("{}")), + Header: http.Header{}, + }, nil + }), "BitBoxApp/1.2.3 (linux)"), + } + + req, err := http.NewRequest(http.MethodGet, "https://fees1.shiftcrypto.io/api/v1/fees/recommended", nil) + require.NoError(t, err) + req.Header.Set("User-Agent", "custom-agent") + + _, err = client.Do(req) + require.NoError(t, err) +} + +func TestTransportUsesDefaultTransportWhenBaseIsNil(t *testing.T) { + require.NotPanics(t, func() { + require.NotNil(t, NewTransport(nil, "BitBoxApp/1.2.3 (linux)")) + }) +}