Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 54 additions & 13 deletions Bitkit/Components/ShopWebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,18 @@ struct ShopWebView: UIViewRepresentable {
let url: String
var webView: Binding<WKWebView?>?
var onMessage: ((String) -> Void)?

init(url: String, webView: Binding<WKWebView?>? = nil, onMessage: ((String) -> Void)? = nil) {
var onBlockedNavigation: (() -> Void)?

init(
url: String,
webView: Binding<WKWebView?>? = nil,
onMessage: ((String) -> Void)? = nil,
onBlockedNavigation: (() -> Void)? = nil
) {
self.url = url
self.webView = webView
self.onMessage = onMessage
self.onBlockedNavigation = onBlockedNavigation
}

func makeCoordinator() -> Coordinator {
Expand Down Expand Up @@ -55,28 +62,52 @@ 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(
_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
decisionHandler(.allow)
if navigationAction.targetFrame?.isMainFrame == false {
decisionHandler(.allow)
return
Comment thread
ben-kaufman marked this conversation as resolved.
}
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)
}
}

Expand All @@ -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
}
}
}
1 change: 1 addition & 0 deletions Bitkit/Resources/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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" = "<accent>Safely store</accent> your Bitcoin";
"security__backup_funds" = "Now that you have some funds in your wallet, it is time to back up your money!";
Expand Down
53 changes: 53 additions & 0 deletions Bitkit/Utilities/ShopOrigin.swift
Original file line number Diff line number Diff line change
@@ -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));
});
}
"""
}
}
26 changes: 26 additions & 0 deletions Bitkit/Utilities/ShopPaymentRequest.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
48 changes: 40 additions & 8 deletions Bitkit/ViewModels/AppViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"),
Expand All @@ -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
Expand Down
20 changes: 14 additions & 6 deletions Bitkit/Views/Shop/ShopMain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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,
Expand Down
Loading
Loading