Skip to content
Open
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
8 changes: 8 additions & 0 deletions Sources/Logger/LogEvents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,14 @@ public enum LogEvents {
/// Gave up recovering after repeated content process terminations
public static let webProcessRecoveryGivenUp = "signer.webProcess.recovery.givenUp"

// MARK: - Signer Page Origin Events

/// Dropped a message that did not come from the main frame of the loaded signer origin
public static let messageDropped = "signer.message.dropped"

/// Blocked a main frame navigation away from the loaded signer origin
public static let navigationBlocked = "signer.navigation.blocked"

// MARK: - Wallet Factory Events

/// Getting wallet
Expand Down
41 changes: 41 additions & 0 deletions Sources/Web/WebViewCommunicationProxy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,16 @@ public class DefaultWebViewCommunicationProxy: NSObject, ObservableObject, WKScr
return
}

let frame = message.frameInfo
let origin = frame.securityOrigin
guard frame.isMainFrame, isLoadedOrigin(scheme: origin.protocol, host: origin.host, port: origin.port) else {
Logger.web.warning(LogEvents.messageDropped, attributes: [
"origin": "\(origin.protocol)://\(origin.host)",
"mainFrame": frame.isMainFrame
])
return
}

Task { @MainActor in
messageHandler.processIncomingMessage(message.body)
}
Expand Down Expand Up @@ -210,9 +220,40 @@ public class DefaultWebViewCommunicationProxy: NSObject, ObservableObject, WKScr

return loadedContent != url
}

func allowsNavigation(to url: URL?, inMainFrame mainFrame: Bool) -> Bool {
!mainFrame || isLoadedOrigin(scheme: url?.scheme, host: url?.host, port: url?.port)
}

private func isLoadedOrigin(scheme: String?, host: String?, port: Int?) -> Bool {
guard let loadedContent else {
return false
}

return loadedContent.scheme?.lowercased() == scheme?.lowercased()
&& loadedContent.host?.lowercased() == host?.lowercased()
&& (loadedContent.port ?? 0) == (port ?? 0)
}
}

extension DefaultWebViewCommunicationProxy: WKNavigationDelegate {
public func webView(
_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void
) {
let url = navigationAction.request.url
guard allowsNavigation(to: url, inMainFrame: navigationAction.targetFrame?.isMainFrame ?? true) else {
Logger.web.warning(LogEvents.navigationBlocked, attributes: [
"origin": "\(url?.scheme ?? "")://\(url?.host ?? "")"
])
decisionHandler(.cancel)
return
}

decisionHandler(.allow)
}

public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
Logger.web.debug("Webview finished loading")
isPageLoaded = true
Expand Down
117 changes: 117 additions & 0 deletions Tests/WebTests/Helpers/SignerPageFixture.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
//
// SignerPageFixture.swift
// CrossmintSDK
//
// Created by Tomas Martins on 30/10/26.
//

import Foundation
import WebKit
@testable import Web

@MainActor
final class SignerPageFixture {
static let SIGNER_URL = "crossmint-test://signer/"
static let SIGNER_SECOND_PAGE_URL = "crossmint-test://signer/second"
static let FOREIGN_URL = "crossmint-test://foreign/"

private static let PAGES: [String: String] = [
SIGNER_URL: page(posting: "from.signer"),
SIGNER_SECOND_PAGE_URL: page(posting: "from.signer.second"),
"crossmint-test://signer/frame": """
<script>
\(post("from.iframe"))
parent.postMessage("posted", "*");
</script>
""",
FOREIGN_URL: """
<script>
window.addEventListener("message", () => {
\(post("from.foreign"))
window.webkit.messageHandlers.probe.postMessage("foreign.done");
});
</script>
<iframe src="crossmint-test://signer/frame"></iframe>
"""
]

let proxy = DefaultWebViewCommunicationProxy()
let webView: WKWebView
let recorder = MessageRecorder()
private let schemeHandler = PageSchemeHandler(pages: PAGES)

init() {
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .nonPersistent()
configuration.setURLSchemeHandler(schemeHandler, forURLScheme: "crossmint-test")

let userContentController = WKUserContentController()
userContentController.add(webViewCommunicationProxy: proxy)
userContentController.add(recorder, name: "probe")
configuration.userContentController = userContentController

webView = WKWebView(frame: .zero, configuration: configuration)
webView.navigationDelegate = proxy
proxy.webView = webView
proxy.onUnknownMessage = { [recorder] type, _ in recorder.record(type) }
}

private static func page(posting type: String) -> String {
"<script>\(post(type))</script>"
}

private static func post(_ type: String) -> String {
"window.webkit.messageHandlers.crossmintMessageHandler.postMessage(JSON.stringify({ type: \"\(type)\" }));"
}
}

@MainActor
final class MessageRecorder: NSObject, WKScriptMessageHandler {
private(set) var types: [String] = []
private var waiters: [String: CheckedContinuation<Void, Never>] = [:]

func record(_ type: String) {
types.append(type)
waiters.removeValue(forKey: type)?.resume()
}

func wait(for type: String) async {
guard !types.contains(type) else { return }
await withCheckedContinuation { waiters[type] = $0 }
Comment thread
tomas-martins-crossmint marked this conversation as resolved.
Resolved
}

func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
guard let type = message.body as? String else { return }
record(type)
}
}

final class PageSchemeHandler: NSObject, WKURLSchemeHandler {
private let pages: [String: String]

init(pages: [String: String]) {
self.pages = pages
}

func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) {
guard let url = urlSchemeTask.request.url, let html = pages[url.absoluteString] else {
urlSchemeTask.didFailWithError(URLError(.fileDoesNotExist))
return
}

let data = Data(html.utf8)
urlSchemeTask.didReceive(URLResponse(
url: url,
mimeType: "text/html",
expectedContentLength: data.count,
textEncodingName: "utf-8"
))
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
}

func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) {}
}
70 changes: 70 additions & 0 deletions Tests/WebTests/WebViewCommunicationProxyOriginTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//
// WebViewCommunicationProxyOriginTests.swift
// CrossmintSDK
//
// Created by Tomas Martins on 30/10/26.
//

import Foundation
import Testing
import WebKit
@testable import Web

@Suite("Signer page origin", .tags(.unit), .timeLimit(.minutes(1)))
@MainActor
struct WebViewCommunicationProxyOriginTests {
@Test func deliversMainFrameMessagesFromLoadedOrigin() async throws {
let fixture = SignerPageFixture()

try await fixture.proxy.loadURL(#require(URL(string: SignerPageFixture.SIGNER_URL)))
await fixture.recorder.wait(for: "from.signer")

#expect(fixture.recorder.types == ["from.signer"])
}

@Test func dropsMessagesFromForeignPageAndItsIframe() async throws {
let fixture = SignerPageFixture()
try await fixture.proxy.loadURL(#require(URL(string: SignerPageFixture.SIGNER_URL)))
await fixture.recorder.wait(for: "from.signer")

fixture.webView.navigationDelegate = nil
try fixture.webView.load(URLRequest(url: #require(URL(string: SignerPageFixture.FOREIGN_URL))))
await fixture.recorder.wait(for: "foreign.done")

fixture.webView.navigationDelegate = fixture.proxy
try fixture.webView.load(URLRequest(url: #require(URL(string: SignerPageFixture.SIGNER_SECOND_PAGE_URL))))
await fixture.recorder.wait(for: "from.signer.second")

#expect(fixture.recorder.types == ["from.signer", "foreign.done", "from.signer.second"])
}

struct NavigationCase: Sendable, CustomTestStringConvertible {
let url: String
let mainFrame: Bool
let allowed: Bool

var testDescription: String { "\(url) in \(mainFrame ? "main frame" : "subframe")" }
}

@Test(arguments: [
NavigationCase(url: "crossmint-test://signer/second", mainFrame: true, allowed: true),
NavigationCase(url: "crossmint-test://foreign/", mainFrame: true, allowed: false),
NavigationCase(url: "https://signer/", mainFrame: true, allowed: false),
NavigationCase(url: "crossmint-test://signer:8080/", mainFrame: true, allowed: false),
NavigationCase(url: "crossmint-test://foreign/", mainFrame: false, allowed: true)
])
func allowsMainFrameNavigationOnlyWithinLoadedOrigin(_ navigation: NavigationCase) async throws {
let fixture = SignerPageFixture()
try await fixture.proxy.loadURL(#require(URL(string: SignerPageFixture.SIGNER_URL)))
let url = try #require(URL(string: navigation.url))

#expect(fixture.proxy.allowsNavigation(to: url, inMainFrame: navigation.mainFrame) == navigation.allowed)
Comment thread
tomas-martins-crossmint marked this conversation as resolved.
Resolved
}

@Test func blocksMainFrameNavigationBeforeAnyPageLoads() throws {
let fixture = SignerPageFixture()
let url = try #require(URL(string: SignerPageFixture.SIGNER_URL))

#expect(!fixture.proxy.allowsNavigation(to: url, inMainFrame: true))
}
}
Loading