Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
41 changes: 30 additions & 11 deletions Bitkit/Components/ShopWebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,28 +55,41 @@ 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(
_ 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.isAllowed(navigationAction.request.url) {
decisionHandler(.allow)
return
}
Comment thread
ovitrif marked this conversation as resolved.
Outdated
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)
}
}

Expand All @@ -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
}
}
}
8 changes: 8 additions & 0 deletions Bitkit/Utilities/PaymentNavigationHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
33 changes: 33 additions & 0 deletions Bitkit/Utilities/ShopOrigin.swift
Original file line number Diff line number Diff line change
@@ -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));
});
"""
}
}
3 changes: 2 additions & 1 deletion Bitkit/Views/Shop/ShopMain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
22 changes: 22 additions & 0 deletions BitkitTests/PaymentNavigationHelperTests.swift
Original file line number Diff line number Diff line change
@@ -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)
)
}
}
31 changes: 31 additions & 0 deletions BitkitTests/ShopOriginTests.swift
Original file line number Diff line number Diff line change
@@ -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 ="))
}
}
1 change: 1 addition & 0 deletions changelog.d/next/shop-quickpay-auth.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Shop checkout only accepts Bitrefill payment requests, and QuickPay now honors PIN protection.
Loading