From c1c629d68107c40056d32a376ed76d68e8e8023e Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 13 Aug 2026 14:23:28 -0500 Subject: [PATCH 1/4] fix: gate shop origin and pin pay Co-authored-by: Cursor --- Bitkit/Components/ShopWebView.swift | 41 ++++++++++++++----- .../Utilities/PaymentNavigationHelper.swift | 8 ++++ Bitkit/Utilities/ShopOrigin.swift | 33 +++++++++++++++ Bitkit/Views/Shop/ShopMain.swift | 3 +- .../PaymentNavigationHelperTests.swift | 22 ++++++++++ BitkitTests/ShopOriginTests.swift | 31 ++++++++++++++ .../next/shop-quickpay-auth.security.md | 1 + 7 files changed, 127 insertions(+), 12 deletions(-) create mode 100644 Bitkit/Utilities/ShopOrigin.swift create mode 100644 BitkitTests/PaymentNavigationHelperTests.swift create mode 100644 BitkitTests/ShopOriginTests.swift create mode 100644 changelog.d/next/shop-quickpay-auth.security.md diff --git a/Bitkit/Components/ShopWebView.swift b/Bitkit/Components/ShopWebView.swift index 6e8d590eb..204759290 100644 --- a/Bitkit/Components/ShopWebView.swift +++ b/Bitkit/Components/ShopWebView.swift @@ -55,9 +55,15 @@ 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 } + guard ShopOrigin.isAllowed(message.webView?.url) else { + Logger.warn( + "Rejected shop payment_intent from untrusted origin '\(message.webView?.url?.absoluteString ?? "")'", + context: "ShopWebView" + ) + return } + parent.onMessage?(body) } func webView( @@ -65,18 +71,25 @@ struct ShopWebView: UIViewRepresentable { decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void ) { - decisionHandler(.allow) + if navigationAction.targetFrame?.isMainFrame == false { + decisionHandler(.allow) + return + } + if ShopOrigin.isAllowed(navigationAction.request.url) { + decisionHandler(.allow) + return + } + Logger.warn( + "Blocked shop navigation to untrusted origin '\(navigationAction.request.url?.absoluteString ?? "")'", + context: "ShopWebView" + ) + 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 +99,15 @@ 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.isAllowed(navigationAction.request.url) else { + Logger.warn( + "Blocked shop window navigation to untrusted origin '\(navigationAction.request.url?.absoluteString ?? "")'", + context: "ShopWebView" + ) + return nil + } webView.load(navigationAction.request) - return nil // Return nil to use the current WebView + return nil } } } diff --git a/Bitkit/Utilities/PaymentNavigationHelper.swift b/Bitkit/Utilities/PaymentNavigationHelper.swift index a9c30fd11..ad3e77c1e 100644 --- a/Bitkit/Utilities/PaymentNavigationHelper.swift +++ b/Bitkit/Utilities/PaymentNavigationHelper.swift @@ -19,6 +19,10 @@ struct PaymentNavigationHelper { return false } + if isBlockedByPaymentPin(pinEnabled: settings.pinEnabled, requirePinForPayments: settings.requirePinForPayments) { + return false + } + // We need a lightning invoice or LNURL pay data to use quickpay guard app.scannedLightningInvoice != nil || app.lnurlPayData != nil else { return false @@ -39,6 +43,10 @@ struct PaymentNavigationHelper { return app.scannedLightningInvoice!.amountSatoshis <= quickpayAmountSats } + nonisolated static func isBlockedByPaymentPin(pinEnabled: Bool, requirePinForPayments: Bool) -> Bool { + pinEnabled && requirePinForPayments + } + /// Centralized method to open the appropriate sheet based on the current state static func openPaymentSheet( app: AppViewModel, diff --git a/Bitkit/Utilities/ShopOrigin.swift b/Bitkit/Utilities/ShopOrigin.swift new file mode 100644 index 000000000..1cf6d6de4 --- /dev/null +++ b/Bitkit/Utilities/ShopOrigin.swift @@ -0,0 +1,33 @@ +import Foundation + +enum ShopOrigin { + static let rootHost = "bitrefill.com" + + 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 var messageBridgeScript: String { + """ + window.addEventListener('message', function(event) { + try { + var originUrl = new URL(event.origin); + if (originUrl.protocol !== 'https:') return; + var host = originUrl.hostname.toLowerCase(); + if (host !== '\(rootHost)' && !host.endsWith('.\(rootHost)')) return; + } catch (e) { + return; + } + window.webkit.messageHandlers.messageHandler.postMessage(JSON.stringify(event.data)); + }); + """ + } +} diff --git a/Bitkit/Views/Shop/ShopMain.swift b/Bitkit/Views/Shop/ShopMain.swift index 38c4cc73a..c46b53a07 100644 --- a/Bitkit/Views/Shop/ShopMain.swift +++ b/Bitkit/Views/Shop/ShopMain.swift @@ -39,7 +39,8 @@ 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 } diff --git a/BitkitTests/PaymentNavigationHelperTests.swift b/BitkitTests/PaymentNavigationHelperTests.swift new file mode 100644 index 000000000..788c2f87b --- /dev/null +++ b/BitkitTests/PaymentNavigationHelperTests.swift @@ -0,0 +1,22 @@ +@testable import Bitkit +import XCTest + +final class PaymentNavigationHelperTests: XCTestCase { + func testQuickpayIsBlockedWhenPinIsRequiredForPayments() { + XCTAssertTrue( + PaymentNavigationHelper.isBlockedByPaymentPin(pinEnabled: true, requirePinForPayments: true) + ) + } + + func testQuickpayIsAllowedWhenPinForPaymentsIsOff() { + XCTAssertFalse( + PaymentNavigationHelper.isBlockedByPaymentPin(pinEnabled: true, requirePinForPayments: false) + ) + XCTAssertFalse( + PaymentNavigationHelper.isBlockedByPaymentPin(pinEnabled: false, requirePinForPayments: true) + ) + XCTAssertFalse( + PaymentNavigationHelper.isBlockedByPaymentPin(pinEnabled: false, requirePinForPayments: false) + ) + } +} diff --git a/BitkitTests/ShopOriginTests.swift b/BitkitTests/ShopOriginTests.swift new file mode 100644 index 000000000..c71d55234 --- /dev/null +++ b/BitkitTests/ShopOriginTests.swift @@ -0,0 +1,31 @@ +@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("addEventListener('message'")) + XCTAssertTrue(script.contains("bitrefill.com")) + XCTAssertFalse(script.contains("window.postMessage =")) + } +} diff --git a/changelog.d/next/shop-quickpay-auth.security.md b/changelog.d/next/shop-quickpay-auth.security.md new file mode 100644 index 000000000..787714c5b --- /dev/null +++ b/changelog.d/next/shop-quickpay-auth.security.md @@ -0,0 +1 @@ +Shop checkout only accepts Bitrefill payment requests, and QuickPay now honors PIN protection. From dc0f3e2234db2ebe307388c8242fac177b4813d3 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 13 Aug 2026 14:24:21 -0500 Subject: [PATCH 2/4] chore: rename changelog fragment Co-authored-by: Cursor --- .../next/{shop-quickpay-auth.security.md => 668.security.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{shop-quickpay-auth.security.md => 668.security.md} (100%) diff --git a/changelog.d/next/shop-quickpay-auth.security.md b/changelog.d/next/668.security.md similarity index 100% rename from changelog.d/next/shop-quickpay-auth.security.md rename to changelog.d/next/668.security.md From a23456a670eae2d6ec2989142681c7a55bb7d409 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 13 Aug 2026 14:29:34 -0500 Subject: [PATCH 3/4] fix: keep btc map shop navigation Co-authored-by: Cursor --- Bitkit/Components/ShopWebView.swift | 10 +++++++-- Bitkit/Utilities/ShopOrigin.swift | 9 ++++++++ BitkitTests/ShopOriginTests.swift | 35 +++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/Bitkit/Components/ShopWebView.swift b/Bitkit/Components/ShopWebView.swift index 204759290..d47611683 100644 --- a/Bitkit/Components/ShopWebView.swift +++ b/Bitkit/Components/ShopWebView.swift @@ -75,7 +75,10 @@ struct ShopWebView: UIViewRepresentable { decisionHandler(.allow) return } - if ShopOrigin.isAllowed(navigationAction.request.url) { + if ShopOrigin.shouldAllowMainFrameNavigation( + to: navigationAction.request.url, + initialUrl: parent.url + ) { decisionHandler(.allow) return } @@ -99,7 +102,10 @@ struct ShopWebView: UIViewRepresentable { for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures ) -> WKWebView? { - guard ShopOrigin.isAllowed(navigationAction.request.url) else { + 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" diff --git a/Bitkit/Utilities/ShopOrigin.swift b/Bitkit/Utilities/ShopOrigin.swift index 1cf6d6de4..85e5f24dc 100644 --- a/Bitkit/Utilities/ShopOrigin.swift +++ b/Bitkit/Utilities/ShopOrigin.swift @@ -15,6 +15,15 @@ enum ShopOrigin { return isAllowedHost(url.host) } + 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 { """ window.addEventListener('message', function(event) { diff --git a/BitkitTests/ShopOriginTests.swift b/BitkitTests/ShopOriginTests.swift index c71d55234..1b81bb1b6 100644 --- a/BitkitTests/ShopOriginTests.swift +++ b/BitkitTests/ShopOriginTests.swift @@ -28,4 +28,39 @@ final class ShopOriginTests: XCTestCase { XCTAssertTrue(script.contains("bitrefill.com")) XCTAssertFalse(script.contains("window.postMessage =")) } + + 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 + ) + ) + } } From a1a2fa8712c3d9f80925dac57231a2650f603b0e Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 17 Aug 2026 13:51:41 -0500 Subject: [PATCH 4/4] fix: harden shop payment bridge --- Bitkit/Components/ShopWebView.swift | 24 ++++- .../Localization/en.lproj/Localizable.strings | 1 + .../Utilities/PaymentNavigationHelper.swift | 8 -- Bitkit/Utilities/ShopOrigin.swift | 33 ++++--- Bitkit/Utilities/ShopPaymentRequest.swift | 26 ++++++ Bitkit/ViewModels/AppViewModel.swift | 48 ++++++++-- Bitkit/Views/Shop/ShopMain.swift | 17 ++-- .../PaymentNavigationHelperTests.swift | 88 ++++++++++++++++--- BitkitTests/ShopOriginTests.swift | 54 +++++++++++- BitkitTests/ShopPaymentRequestTests.swift | 46 ++++++++++ changelog.d/next/668.security.md | 2 +- 11 files changed, 298 insertions(+), 49 deletions(-) create mode 100644 Bitkit/Utilities/ShopPaymentRequest.swift create mode 100644 BitkitTests/ShopPaymentRequestTests.swift diff --git a/Bitkit/Components/ShopWebView.swift b/Bitkit/Components/ShopWebView.swift index d47611683..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 { @@ -56,9 +63,16 @@ struct ShopWebView: UIViewRepresentable { func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { guard message.name == "messageHandler", let body = message.body as? String else { return } - guard ShopOrigin.isAllowed(message.webView?.url) else { + 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 origin '\(message.webView?.url?.absoluteString ?? "")'", + "Rejected shop payment_intent from untrusted sender '\(securityOrigin.protocol)://\(securityOrigin.host):\(securityOrigin.port)'", context: "ShopWebView" ) return @@ -86,6 +100,7 @@ struct ShopWebView: UIViewRepresentable { "Blocked shop navigation to untrusted origin '\(navigationAction.request.url?.absoluteString ?? "")'", context: "ShopWebView" ) + parent.onBlockedNavigation?() decisionHandler(.cancel) } @@ -110,6 +125,7 @@ struct ShopWebView: UIViewRepresentable { "Blocked shop window navigation to untrusted origin '\(navigationAction.request.url?.absoluteString ?? "")'", context: "ShopWebView" ) + parent.onBlockedNavigation?() return nil } webView.load(navigationAction.request) diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index e6fb056ab..f4bcfb12b 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -541,6 +541,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/PaymentNavigationHelper.swift b/Bitkit/Utilities/PaymentNavigationHelper.swift index ad3e77c1e..a9c30fd11 100644 --- a/Bitkit/Utilities/PaymentNavigationHelper.swift +++ b/Bitkit/Utilities/PaymentNavigationHelper.swift @@ -19,10 +19,6 @@ struct PaymentNavigationHelper { return false } - if isBlockedByPaymentPin(pinEnabled: settings.pinEnabled, requirePinForPayments: settings.requirePinForPayments) { - return false - } - // We need a lightning invoice or LNURL pay data to use quickpay guard app.scannedLightningInvoice != nil || app.lnurlPayData != nil else { return false @@ -43,10 +39,6 @@ struct PaymentNavigationHelper { return app.scannedLightningInvoice!.amountSatoshis <= quickpayAmountSats } - nonisolated static func isBlockedByPaymentPin(pinEnabled: Bool, requirePinForPayments: Bool) -> Bool { - pinEnabled && requirePinForPayments - } - /// Centralized method to open the appropriate sheet based on the current state static func openPaymentSheet( app: AppViewModel, diff --git a/Bitkit/Utilities/ShopOrigin.swift b/Bitkit/Utilities/ShopOrigin.swift index 85e5f24dc..b638d827e 100644 --- a/Bitkit/Utilities/ShopOrigin.swift +++ b/Bitkit/Utilities/ShopOrigin.swift @@ -2,6 +2,8 @@ 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 } @@ -15,6 +17,19 @@ enum ShopOrigin { 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)) } @@ -26,17 +41,13 @@ enum ShopOrigin { static var messageBridgeScript: String { """ - window.addEventListener('message', function(event) { - try { - var originUrl = new URL(event.origin); - if (originUrl.protocol !== 'https:') return; - var host = originUrl.hostname.toLowerCase(); - if (host !== '\(rootHost)' && !host.endsWith('.\(rootHost)')) return; - } catch (e) { - return; - } - window.webkit.messageHandlers.messageHandler.postMessage(JSON.stringify(event.data)); - }); + 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 6e68f44de..d45af4a4d 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -392,7 +392,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 { @@ -406,23 +410,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"), @@ -432,8 +459,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 c46b53a07..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), @@ -47,7 +54,7 @@ struct ShopMain: View { 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 index 788c2f87b..16dd13d44 100644 --- a/BitkitTests/PaymentNavigationHelperTests.swift +++ b/BitkitTests/PaymentNavigationHelperTests.swift @@ -1,22 +1,88 @@ @testable import Bitkit +import BitkitCore import XCTest +@MainActor final class PaymentNavigationHelperTests: XCTestCase { - func testQuickpayIsBlockedWhenPinIsRequiredForPayments() { - XCTAssertTrue( - PaymentNavigationHelper.isBlockedByPaymentPin(pinEnabled: true, requirePinForPayments: true) - ) + 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 testQuickpayIsAllowedWhenPinForPaymentsIsOff() { - XCTAssertFalse( - PaymentNavigationHelper.isBlockedByPaymentPin(pinEnabled: true, requirePinForPayments: false) + func testPaymentPinDoesNotChangeEligibleQuickpayRoute() { + settings.pinEnabled = true + settings.requirePinForPayments = true + + XCTAssertEqual( + PaymentNavigationHelper.appropriateSendRoute( + app: appWithEligibleInvoice, + currency: CurrencyViewModel(), + settings: settings + ), + .quickpay ) - XCTAssertFalse( - PaymentNavigationHelper.isBlockedByPaymentPin(pinEnabled: false, requirePinForPayments: true) + } + + 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 ) - XCTAssertFalse( - PaymentNavigationHelper.isBlockedByPaymentPin(pinEnabled: false, requirePinForPayments: false) + 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 index 1b81bb1b6..9eb944790 100644 --- a/BitkitTests/ShopOriginTests.swift +++ b/BitkitTests/ShopOriginTests.swift @@ -24,11 +24,63 @@ final class ShopOriginTests: XCTestCase { func testBridgeScriptChecksMessageOrigin() { let script = ShopOrigin.messageBridgeScript + XCTAssertTrue(script.contains("if (!window.__bitkitShopBridgeInstalled)")) XCTAssertTrue(script.contains("addEventListener('message'")) - XCTAssertTrue(script.contains("bitrefill.com")) + 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)) 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 index 787714c5b..ed0fc446f 100644 --- a/changelog.d/next/668.security.md +++ b/changelog.d/next/668.security.md @@ -1 +1 @@ -Shop checkout only accepts Bitrefill payment requests, and QuickPay now honors PIN protection. +Shop checkout now accepts only payment requests from the trusted Bitrefill embed origin.