diff --git a/Bitkit/Components/ShopWebView.swift b/Bitkit/Components/ShopWebView.swift index 6e8d590eb..5adbfc2c5 100644 --- a/Bitkit/Components/ShopWebView.swift +++ b/Bitkit/Components/ShopWebView.swift @@ -6,11 +6,18 @@ struct ShopWebView: UIViewRepresentable { let url: String var webView: Binding? var onMessage: ((String) -> Void)? - - init(url: String, webView: Binding? = nil, onMessage: ((String) -> Void)? = nil) { + var onBlockedNavigation: (() -> Void)? + + init( + url: String, + webView: Binding? = nil, + onMessage: ((String) -> Void)? = nil, + onBlockedNavigation: (() -> Void)? = nil + ) { self.url = url self.webView = webView self.onMessage = onMessage + self.onBlockedNavigation = onBlockedNavigation } func makeCoordinator() -> Coordinator { @@ -55,9 +62,22 @@ struct ShopWebView: UIViewRepresentable { } func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - if message.name == "messageHandler", let body = message.body as? String { - parent.onMessage?(body) + guard message.name == "messageHandler", let body = message.body as? String else { return } + let frameInfo = message.frameInfo + let securityOrigin = frameInfo.securityOrigin + guard ShopOrigin.isAllowedMessageSender( + isMainFrame: frameInfo.isMainFrame, + scheme: securityOrigin.protocol, + host: securityOrigin.host, + port: securityOrigin.port + ) else { + Logger.warn( + "Rejected shop payment_intent from untrusted sender '\(securityOrigin.protocol)://\(securityOrigin.host):\(securityOrigin.port)'", + context: "ShopWebView" + ) + return } + parent.onMessage?(body) } func webView( @@ -65,18 +85,29 @@ struct ShopWebView: UIViewRepresentable { decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void ) { - decisionHandler(.allow) + if navigationAction.targetFrame?.isMainFrame == false { + decisionHandler(.allow) + return + } + if ShopOrigin.shouldAllowMainFrameNavigation( + to: navigationAction.request.url, + initialUrl: parent.url + ) { + decisionHandler(.allow) + return + } + Logger.warn( + "Blocked shop navigation to untrusted origin '\(navigationAction.request.url?.absoluteString ?? "")'", + context: "ShopWebView" + ) + parent.onBlockedNavigation?() + decisionHandler(.cancel) } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { // Inject JavaScript to capture postMessage events if message handler is configured if parent.onMessage != nil { - let script = """ - window.addEventListener('message', function(event) { - window.webkit.messageHandlers.messageHandler.postMessage(JSON.stringify(event.data)); - }); - """ - webView.evaluateJavaScript(script) + webView.evaluateJavaScript(ShopOrigin.messageBridgeScript) } } @@ -86,9 +117,19 @@ struct ShopWebView: UIViewRepresentable { for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures ) -> WKWebView? { - // Load the navigation request in the current WebView instead of opening a new window + guard ShopOrigin.shouldAllowMainFrameNavigation( + to: navigationAction.request.url, + initialUrl: parent.url + ) else { + Logger.warn( + "Blocked shop window navigation to untrusted origin '\(navigationAction.request.url?.absoluteString ?? "")'", + context: "ShopWebView" + ) + parent.onBlockedNavigation?() + return nil + } webView.load(navigationAction.request) - return nil // Return nil to use the current WebView + return nil } } } diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index d726ff5ec..b31175674 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -552,6 +552,7 @@ "other__shop__discover__travel__title" = "Travel"; "other__shop__discover__travel__description" = "Book your ₿ holiday"; "other__shop__main__nav_title" = "Shop"; +"other__shop__external_link_blocked" = "This link can’t be opened from the shop."; "security__backup_wallet" = "Wallet Backup"; "security__backup_title" = "Safely store your Bitcoin"; "security__backup_funds" = "Now that you have some funds in your wallet, it is time to back up your money!"; diff --git a/Bitkit/Utilities/ShopOrigin.swift b/Bitkit/Utilities/ShopOrigin.swift new file mode 100644 index 000000000..b638d827e --- /dev/null +++ b/Bitkit/Utilities/ShopOrigin.swift @@ -0,0 +1,53 @@ +import Foundation + +enum ShopOrigin { + static let rootHost = "bitrefill.com" + static let paymentOrigin = "https://embed.bitrefill.com" + private static let defaultHttpsPort = 443 + + static func isAllowedHost(_ host: String?) -> Bool { + guard var host = host?.lowercased() else { return false } + host = host.trimmingCharacters(in: CharacterSet(charactersIn: ".")) + return host == rootHost || host.hasSuffix(".\(rootHost)") + } + + static func isAllowed(_ url: URL?) -> Bool { + guard let url else { return false } + guard url.scheme?.lowercased() == "https" else { return false } + return isAllowedHost(url.host) + } + + static func isAllowedMessageSender(isMainFrame: Bool, scheme: String, host: String, port: Int) -> Bool { + guard let expectedOrigin = URL(string: paymentOrigin), + let expectedScheme = expectedOrigin.scheme, + let expectedHost = expectedOrigin.host + else { + return false + } + return isMainFrame + && scheme.lowercased() == expectedScheme + && host.lowercased() == expectedHost + && (port == 0 || port == defaultHttpsPort) + } + + static func shouldRestrictNavigation(initialUrl: String) -> Bool { + isAllowed(URL(string: initialUrl)) + } + + static func shouldAllowMainFrameNavigation(to url: URL?, initialUrl: String) -> Bool { + guard shouldRestrictNavigation(initialUrl: initialUrl) else { return true } + return isAllowed(url) + } + + static var messageBridgeScript: String { + """ + if (!window.__bitkitShopBridgeInstalled) { + window.__bitkitShopBridgeInstalled = true; + window.addEventListener('message', function(event) { + if (event.origin !== '\(paymentOrigin)') return; + window.webkit.messageHandlers.messageHandler.postMessage(JSON.stringify(event.data)); + }); + } + """ + } +} diff --git a/Bitkit/Utilities/ShopPaymentRequest.swift b/Bitkit/Utilities/ShopPaymentRequest.swift new file mode 100644 index 000000000..37411fbd2 --- /dev/null +++ b/Bitkit/Utilities/ShopPaymentRequest.swift @@ -0,0 +1,26 @@ +import BitkitCore +import Foundation + +enum ScanHandlingScope { + case unrestricted + case paymentRequests +} + +enum ShopPaymentRequest { + static func isSupported(_ data: BitkitCore.Scanner) -> Bool { + switch data { + case .onChain, .lightning, .lnurlPay: + return true + default: + return false + } + } +} + +enum ShopPaymentRequestError: LocalizedError { + case unsupportedRequest + + var errorDescription: String? { + t("other__scan__error__generic") + } +} diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 48f9d4576..41c385f5e 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -394,7 +394,11 @@ extension AppViewModel { // MARK: Scanning/pasting handling extension AppViewModel { - func handleScannedData(_ uri: String, claimedContactPaymentContext: ContactPaymentContext? = nil) async throws { + func handleScannedData( + _ uri: String, + claimedContactPaymentContext: ContactPaymentContext? = nil, + scope: ScanHandlingScope = .unrestricted + ) async throws { let handlingId = claimedContactPaymentContext?.id ?? UUID() if let claimedContactPaymentContext { guard ownsContactPaymentContext(claimedContactPaymentContext), scannedDataHandlingId == nil else { @@ -408,23 +412,46 @@ extension AppViewModel { } } + let uri = uri.removingLightningSchemes() + let prevalidatedPaymentRequest: BitkitCore.Scanner? + if scope == .paymentRequests { + guard SamRockSetupRequest.parse(uri) == nil, + !SamRockSetupRequest.isProtocolURL(uri) + else { + throw ShopPaymentRequestError.unsupportedRequest + } + if Bip21Utils.isDuplicatedBip21(uri) { + toast( + type: .error, + title: t("other__scan_err_decoding"), + description: t("other__scan__error__generic"), + accessibilityIdentifier: "InvalidAddressToast" + ) + return + } + let data = try await decode(invoice: uri) + try ensureScannedDataHandlingOwnership(handlingId, claimedContactPaymentContext: claimedContactPaymentContext) + guard ShopPaymentRequest.isSupported(data) else { throw ShopPaymentRequestError.unsupportedRequest } + prevalidatedPaymentRequest = data + } else { + prevalidatedPaymentRequest = nil + } + // Reset send state before handling new data resetSendState(preservingContactPaymentContext: claimedContactPaymentContext != nil) - let uri = uri.removingLightningSchemes() - - if let samRockSetup = SamRockSetupRequest.parse(uri) { + if scope == .unrestricted, let samRockSetup = SamRockSetupRequest.parse(uri) { handleBTCPayConnection(samRockSetup) return } - if SamRockSetupRequest.isProtocolURL(uri) { + if scope == .unrestricted, SamRockSetupRequest.isProtocolURL(uri) { handleInvalidBTCPayConnection(uri) return } // Workaround for duplicated BIP21 URIs (bitkit-core#63) - if Bip21Utils.isDuplicatedBip21(uri) { + if scope == .unrestricted, Bip21Utils.isDuplicatedBip21(uri) { toast( type: .error, title: t("other__scan_err_decoding"), @@ -434,8 +461,13 @@ extension AppViewModel { return } - let data = try await decode(invoice: uri) - try ensureScannedDataHandlingOwnership(handlingId, claimedContactPaymentContext: claimedContactPaymentContext) + let data: BitkitCore.Scanner + if let prevalidatedPaymentRequest { + data = prevalidatedPaymentRequest + } else { + data = try await decode(invoice: uri) + try ensureScannedDataHandlingOwnership(handlingId, claimedContactPaymentContext: claimedContactPaymentContext) + } switch data { // BIP21 (Unified) invoice handling diff --git a/Bitkit/Views/Shop/ShopMain.swift b/Bitkit/Views/Shop/ShopMain.swift index 38c4cc73a..5c4ac4fe3 100644 --- a/Bitkit/Views/Shop/ShopMain.swift +++ b/Bitkit/Views/Shop/ShopMain.swift @@ -13,24 +13,31 @@ struct ShopMain: View { let navTitle = t("other__shop__main__nav_title") private var uri: String { - let baseUrl = "https://embed.bitrefill.com" let paymentMethod = "bitcoin" // Payment method "bitcoin" gives a unified invoice let params = "?ref=\(Env.bitrefillRef)&paymentMethod=\(paymentMethod)&theme=dark&utm_source=\(Env.appName)" - return "\(baseUrl)/\(page)/\(params)" + return "\(ShopOrigin.paymentOrigin)/\(page)/\(params)" } var body: some View { VStack(spacing: 0) { NavigationBar(title: navTitle) - ShopWebView(url: uri, onMessage: handleMessage) - .padding(.top, 16) + ShopWebView( + url: uri, + onMessage: handleMessage, + onBlockedNavigation: handleBlockedNavigation + ) + .padding(.top, 16) } .navigationBarHidden(true) .padding(.horizontal, 16) .offlineOverlay(title: navTitle) } + private func handleBlockedNavigation() { + app.toast(type: .warning, title: navTitle, description: t("other__shop__external_link_blocked")) + } + private func handleMessage(_ message: String) { // Parse the message as a JSON-encoded string guard let messageData = message.data(using: .utf8), @@ -39,14 +46,15 @@ struct ShopMain: View { let json = try? JSONSerialization.jsonObject(with: innerData) as? [String: Any], let event = json["event"] as? String, event == "payment_intent", - let paymentUri = json["paymentUri"] as? String + let paymentUri = (json["paymentUri"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), + !paymentUri.isEmpty else { return } Task { @MainActor in do { - try await app.handleScannedData(paymentUri) + try await app.handleScannedData(paymentUri, scope: .paymentRequests) PaymentNavigationHelper.openPaymentSheet( app: app, diff --git a/BitkitTests/PaymentNavigationHelperTests.swift b/BitkitTests/PaymentNavigationHelperTests.swift new file mode 100644 index 000000000..16dd13d44 --- /dev/null +++ b/BitkitTests/PaymentNavigationHelperTests.swift @@ -0,0 +1,88 @@ +@testable import Bitkit +import BitkitCore +import XCTest + +@MainActor +final class PaymentNavigationHelperTests: XCTestCase { + private let settings = SettingsViewModel.shared + private var originalEnableQuickpay = false + private var originalQuickpayAmount: Double = 0 + private var originalPinEnabled = false + private var originalRequirePinForPayments = false + private var originalCachedRates: Data? + + override func setUp() { + super.setUp() + originalEnableQuickpay = settings.enableQuickpay + originalQuickpayAmount = settings.quickpayAmount + originalPinEnabled = settings.pinEnabled + originalRequirePinForPayments = settings.requirePinForPayments + originalCachedRates = UserDefaults.standard.data(forKey: "cached_fx_rates") + + settings.enableQuickpay = true + settings.quickpayAmount = 5 + guard let encodedRates = try? JSONEncoder().encode([usdRate]) else { + XCTFail("Failed to encode the QuickPay test exchange rate") + return + } + UserDefaults.standard.set(encodedRates, forKey: "cached_fx_rates") + } + + override func tearDown() { + settings.enableQuickpay = originalEnableQuickpay + settings.quickpayAmount = originalQuickpayAmount + settings.pinEnabled = originalPinEnabled + settings.requirePinForPayments = originalRequirePinForPayments + + if let originalCachedRates { + UserDefaults.standard.set(originalCachedRates, forKey: "cached_fx_rates") + } else { + UserDefaults.standard.removeObject(forKey: "cached_fx_rates") + } + super.tearDown() + } + + func testPaymentPinDoesNotChangeEligibleQuickpayRoute() { + settings.pinEnabled = true + settings.requirePinForPayments = true + + XCTAssertEqual( + PaymentNavigationHelper.appropriateSendRoute( + app: appWithEligibleInvoice, + currency: CurrencyViewModel(), + settings: settings + ), + .quickpay + ) + } + + private var appWithEligibleInvoice: AppViewModel { + let app = AppViewModel() + app.scannedLightningInvoice = LightningInvoice( + bolt11: "test-invoice", + paymentHash: Data(), + amountSatoshis: 1000, + timestampSeconds: 0, + expirySeconds: 0, + isExpired: false, + description: nil, + networkType: .regtest, + payeeNodeId: nil + ) + return app + } + + private var usdRate: FxRate { + FxRate( + symbol: "BTCUSD", + lastPrice: "100000", + base: "BTC", + baseName: "Bitcoin", + quote: "USD", + quoteName: "US Dollar", + currencySymbol: "$", + currencyFlag: "🇺🇸", + lastUpdatedAt: 0 + ) + } +} diff --git a/BitkitTests/ShopOriginTests.swift b/BitkitTests/ShopOriginTests.swift new file mode 100644 index 000000000..9eb944790 --- /dev/null +++ b/BitkitTests/ShopOriginTests.swift @@ -0,0 +1,118 @@ +@testable import Bitkit +import XCTest + +final class ShopOriginTests: XCTestCase { + func testHttpsBitrefillHostsAreAllowed() { + XCTAssertTrue(ShopOrigin.isAllowed(URL(string: "https://embed.bitrefill.com"))) + XCTAssertTrue(ShopOrigin.isAllowed(URL(string: "https://embed.bitrefill.com/gift-cards"))) + XCTAssertTrue(ShopOrigin.isAllowed(URL(string: "https://bitrefill.com"))) + XCTAssertTrue(ShopOrigin.isAllowed(URL(string: "https://www.bitrefill.com/esims"))) + XCTAssertTrue(ShopOrigin.isAllowedHost("embed.bitrefill.com")) + XCTAssertTrue(ShopOrigin.isAllowedHost("BITREFILL.COM")) + } + + func testNonBitrefillAndNonHttpsOriginsAreRejected() { + XCTAssertFalse(ShopOrigin.isAllowed(nil as URL?)) + XCTAssertFalse(ShopOrigin.isAllowed(URL(string: "https://evil.example"))) + XCTAssertFalse(ShopOrigin.isAllowed(URL(string: "https://bitrefill.com.evil.example"))) + XCTAssertFalse(ShopOrigin.isAllowed(URL(string: "https://notbitrefill.com"))) + XCTAssertFalse(ShopOrigin.isAllowed(URL(string: "http://embed.bitrefill.com"))) + XCTAssertFalse(ShopOrigin.isAllowed(URL(string: "javascript:alert(1)"))) + XCTAssertFalse(ShopOrigin.isAllowedHost("evil.example")) + XCTAssertFalse(ShopOrigin.isAllowedHost(nil)) + } + + func testBridgeScriptChecksMessageOrigin() { + let script = ShopOrigin.messageBridgeScript + XCTAssertTrue(script.contains("if (!window.__bitkitShopBridgeInstalled)")) + XCTAssertTrue(script.contains("addEventListener('message'")) + XCTAssertTrue(script.contains("event.origin !== 'https://embed.bitrefill.com'")) + XCTAssertFalse(script.contains("window.postMessage =")) + } + + func testMessageSenderRequiresExactPaymentOriginInMainFrame() { + XCTAssertTrue( + ShopOrigin.isAllowedMessageSender( + isMainFrame: true, + scheme: "https", + host: "embed.bitrefill.com", + port: 0 + ) + ) + XCTAssertTrue( + ShopOrigin.isAllowedMessageSender( + isMainFrame: true, + scheme: "HTTPS", + host: "EMBED.BITREFILL.COM", + port: 443 + ) + ) + XCTAssertFalse( + ShopOrigin.isAllowedMessageSender( + isMainFrame: false, + scheme: "https", + host: "embed.bitrefill.com", + port: 0 + ) + ) + XCTAssertFalse( + ShopOrigin.isAllowedMessageSender( + isMainFrame: true, + scheme: "http", + host: "embed.bitrefill.com", + port: 0 + ) + ) + XCTAssertFalse( + ShopOrigin.isAllowedMessageSender( + isMainFrame: true, + scheme: "https", + host: "www.bitrefill.com", + port: 0 + ) + ) + XCTAssertFalse( + ShopOrigin.isAllowedMessageSender( + isMainFrame: true, + scheme: "https", + host: "embed.bitrefill.com", + port: 8443 + ) + ) + } + + func testBitrefillCheckoutRestrictsMainFrameNavigation() { + let checkout = "https://embed.bitrefill.com/gift-cards" + XCTAssertTrue(ShopOrigin.shouldRestrictNavigation(initialUrl: checkout)) + XCTAssertTrue( + ShopOrigin.shouldAllowMainFrameNavigation( + to: URL(string: "https://www.bitrefill.com/esims"), + initialUrl: checkout + ) + ) + XCTAssertFalse( + ShopOrigin.shouldAllowMainFrameNavigation( + to: URL(string: "https://evil.example"), + initialUrl: checkout + ) + ) + XCTAssertFalse( + ShopOrigin.shouldAllowMainFrameNavigation( + to: URL(string: "https://btcmap.org/map"), + initialUrl: checkout + ) + ) + } + + func testBtcMapDiscoverAllowsNonBitrefillMainFrame() { + let map = "https://btcmap.org/map" + XCTAssertFalse(ShopOrigin.shouldRestrictNavigation(initialUrl: map)) + XCTAssertTrue(ShopOrigin.shouldAllowMainFrameNavigation(to: URL(string: map), initialUrl: map)) + XCTAssertTrue( + ShopOrigin.shouldAllowMainFrameNavigation( + to: URL(string: "https://btcmap.org/merchant/123"), + initialUrl: map + ) + ) + } +} diff --git a/BitkitTests/ShopPaymentRequestTests.swift b/BitkitTests/ShopPaymentRequestTests.swift new file mode 100644 index 000000000..b2f17d3b5 --- /dev/null +++ b/BitkitTests/ShopPaymentRequestTests.swift @@ -0,0 +1,46 @@ +@testable import Bitkit +import BitkitCore +import XCTest + +@MainActor +final class ShopPaymentRequestTests: XCTestCase { + func testLightningInvoiceIsSupported() { + XCTAssertTrue(ShopPaymentRequest.isSupported(.lightning(invoice: lightningInvoice))) + } + + func testNonPaymentScannerDataIsRejected() { + XCTAssertFalse(ShopPaymentRequest.isSupported(.gift(code: "gift-code", amount: 1000))) + XCTAssertFalse(ShopPaymentRequest.isSupported(.pubkyAuth(data: "pubkyauth://example"))) + } + + func testNonPaymentRequestDoesNotClearExistingPaymentState() async { + let app = AppViewModel() + app.scannedLightningInvoice = lightningInvoice + + do { + try await app.handleScannedData( + "https://btcpay.example/plugins/store123/samrock/protocol?setup=btc-chain&otp=abc123", + scope: .paymentRequests + ) + XCTFail("Expected the shop payment scope to reject a setup request") + } catch { + XCTAssertTrue(error is ShopPaymentRequestError) + } + + XCTAssertNotNil(app.scannedLightningInvoice) + } + + private var lightningInvoice: LightningInvoice { + LightningInvoice( + bolt11: "test-invoice", + paymentHash: Data(), + amountSatoshis: 1000, + timestampSeconds: 0, + expirySeconds: 0, + isExpired: false, + description: nil, + networkType: .regtest, + payeeNodeId: nil + ) + } +} diff --git a/changelog.d/next/668.security.md b/changelog.d/next/668.security.md new file mode 100644 index 000000000..ed0fc446f --- /dev/null +++ b/changelog.d/next/668.security.md @@ -0,0 +1 @@ +Shop checkout now accepts only payment requests from the trusted Bitrefill embed origin.