diff --git a/Source/Turbo/Navigator/WKUIController.swift b/Source/Turbo/Navigator/WKUIController.swift index 0cfb4254..65c088b5 100644 --- a/Source/Turbo/Navigator/WKUIController.swift +++ b/Source/Turbo/Navigator/WKUIController.swift @@ -30,4 +30,24 @@ open class WKUIController: NSObject, WKUIDelegate { }) delegate?.present(alert, animated: true) } + + /// Ensures that taps inside an embed that should route out of the iframe are handled. + open func webView( + _ webView: WKWebView, + createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures: WKWindowFeatures + ) -> WKWebView? { + guard navigationAction.request.url != nil else { + return nil + } + + if let targetFrame = navigationAction.targetFrame, + targetFrame.isMainFrame { + return nil + } + + webView.load(navigationAction.request) + return nil + } } diff --git a/Source/Turbo/Navigator/WebViewPolicy/Handlers/ExternalNavigationWebViewPolicyDecisionHandler.swift b/Source/Turbo/Navigator/WebViewPolicy/Handlers/ExternalNavigationWebViewPolicyDecisionHandler.swift index f9ba73a8..28aa957e 100644 --- a/Source/Turbo/Navigator/WebViewPolicy/Handlers/ExternalNavigationWebViewPolicyDecisionHandler.swift +++ b/Source/Turbo/Navigator/WebViewPolicy/Handlers/ExternalNavigationWebViewPolicyDecisionHandler.swift @@ -6,10 +6,21 @@ import WebKit /// /// When such an action is detected, it routes the URL using the provided navigator /// and cancels the web view's default navigation. +/// +/// If the URL's scheme is considered invalid (e.g. `about`, `data`, `file`, +/// `javascript`), the navigation is simply cancelled without routing. This guards +/// against routing blank or unsafe URLs, such as those produced when a new window +/// request from an iframe is loaded into the web view. public struct ExternalNavigationWebViewPolicyDecisionHandler: WebViewPolicyDecisionHandler { public let name: String = "external-navigation-policy" - public init() {} + /// URL schemes that should never be routed externally. Matching navigation + /// actions are cancelled without routing. + public let invalidSchemes: Set + + public init(invalidSchemes: Set = ["about", "data", "file", "javascript"]) { + self.invalidSchemes = invalidSchemes + } public func matches(navigationAction: WKNavigationAction, configuration: Navigator.Configuration) -> Bool { @@ -20,6 +31,11 @@ public struct ExternalNavigationWebViewPolicyDecisionHandler: WebViewPolicyDecis public func handle(navigationAction: WKNavigationAction, configuration: Navigator.Configuration, navigator: Navigator) -> WebViewPolicyManager.Decision { + if let scheme = navigationAction.request.url?.scheme, + invalidSchemes.contains(scheme) { + return .cancel + } + if let url = navigationAction.request.url { navigator.route(url) } diff --git a/Tests/Turbo/WebViewPolicy/BaseWebViewPolicyDecisionHandlerTests.swift b/Tests/Turbo/WebViewPolicy/BaseWebViewPolicyDecisionHandlerTests.swift index 1df3c2bc..e7a31d1b 100644 --- a/Tests/Turbo/WebViewPolicy/BaseWebViewPolicyDecisionHandlerTests.swift +++ b/Tests/Turbo/WebViewPolicy/BaseWebViewPolicyDecisionHandlerTests.swift @@ -4,7 +4,7 @@ import XCTest @MainActor class BaseWebViewPolicyDecisionHandlerTests: XCTestCase { - var webNavigationSimulator: WebViewNavigationSimulator! + var webNavigationSimulator: WebViewPolicyNavigationSimulator! var navigatorSpy: NavigationSpy! let navigatorConfiguration = Navigator.Configuration( name: "test", @@ -13,7 +13,7 @@ class BaseWebViewPolicyDecisionHandlerTests: XCTestCase { override func setUp() async throws { navigatorSpy = NavigationSpy(configuration: navigatorConfiguration) - webNavigationSimulator = WebViewNavigationSimulator() + webNavigationSimulator = WebViewPolicyNavigationSimulator() } override func tearDown() async throws { diff --git a/Tests/Turbo/WebViewPolicy/ExternalNavigationWebViewPolicyDecisionHandlerTests.swift b/Tests/Turbo/WebViewPolicy/ExternalNavigationWebViewPolicyDecisionHandlerTests.swift index 865b4a00..1fbba054 100644 --- a/Tests/Turbo/WebViewPolicy/ExternalNavigationWebViewPolicyDecisionHandlerTests.swift +++ b/Tests/Turbo/WebViewPolicy/ExternalNavigationWebViewPolicyDecisionHandlerTests.swift @@ -14,7 +14,7 @@ final class ExternalNavigationWebViewPolicyDecisionHandlerTests: BaseWebViewPoli func test_link_activated_matches() async throws { guard let action = try await webNavigationSimulator.simulateNavigation( withHTML: .simpleLink, - simulateLinkClickElementId: "link") else { + simulateAction: .click("link")) else { XCTFail("No navigation action captured") return } @@ -25,7 +25,7 @@ final class ExternalNavigationWebViewPolicyDecisionHandlerTests: BaseWebViewPoli func test_handling_link_activated_cancels_web_navigation_and_routes_internally() async throws { guard let action = try await webNavigationSimulator.simulateNavigation( withHTML: .simpleLink, - simulateLinkClickElementId: "link") else { + simulateAction: .click("link")) else { XCTFail("No navigation action captured") return } @@ -42,7 +42,7 @@ final class ExternalNavigationWebViewPolicyDecisionHandlerTests: BaseWebViewPoli func test_js_click_matches() async throws { guard let action = try await webNavigationSimulator.simulateNavigation( withHTML: .jsClick, - simulateLinkClickElementId: nil) else { + simulateAction: .click(nil)) else { XCTFail("No navigation action captured") return } @@ -53,7 +53,7 @@ final class ExternalNavigationWebViewPolicyDecisionHandlerTests: BaseWebViewPoli func test_handling_js_click_cancels_web_navigation_and_routes_internally() async throws { guard let action = try await webNavigationSimulator.simulateNavigation( withHTML: .jsClick, - simulateLinkClickElementId: nil) else { + simulateAction: .click(nil)) else { XCTFail("No navigation action captured") return } @@ -66,4 +66,47 @@ final class ExternalNavigationWebViewPolicyDecisionHandlerTests: BaseWebViewPoli XCTAssertTrue(navigatorSpy.routeWasCalled) XCTAssertEqual(action.request.url, navigatorSpy.routeURL) } + + func test_form_submit_does_not_match() async throws { + guard let action = try await webNavigationSimulator.simulateNavigation( + withHTML: .externalForm, + simulateAction: .submit("form")) else { + XCTFail("No navigation action captured") + return + } + let result = policyHandler.matches(navigationAction: action, configuration: navigatorConfiguration) + XCTAssertFalse(result) + } + + func test_handling_about_scheme_cancels_without_routing() async throws { + guard let action = try await webNavigationSimulator.simulateNavigation( + withHTML: .aboutLink, + simulateAction: .click("link")) else { + XCTFail("No navigation action captured") + return + } + let result = policyHandler.handle( + navigationAction: action, + configuration: navigatorConfiguration, + navigator: navigatorSpy + ) + XCTAssertEqual(result, WebViewPolicyManager.Decision.cancel) + XCTAssertFalse(navigatorSpy.routeWasCalled) + } + + func test_handling_data_scheme_cancels_without_routing() async throws { + guard let action = try await webNavigationSimulator.simulateNavigation( + withHTML: .dataLink, + simulateAction: .click("link")) else { + XCTFail("No navigation action captured") + return + } + let result = policyHandler.handle( + navigationAction: action, + configuration: navigatorConfiguration, + navigator: navigatorSpy + ) + XCTAssertEqual(result, WebViewPolicyManager.Decision.cancel) + XCTAssertFalse(navigatorSpy.routeWasCalled) + } } diff --git a/Tests/Turbo/WebViewPolicy/LinkActivatedWebViewPolicyDecisionHandlerTests.swift b/Tests/Turbo/WebViewPolicy/LinkActivatedWebViewPolicyDecisionHandlerTests.swift index 7903193d..1f1a8bd5 100644 --- a/Tests/Turbo/WebViewPolicy/LinkActivatedWebViewPolicyDecisionHandlerTests.swift +++ b/Tests/Turbo/WebViewPolicy/LinkActivatedWebViewPolicyDecisionHandlerTests.swift @@ -14,7 +14,7 @@ final class LinkActivatedWebViewPolicyDecisionHandlerTests: BaseWebViewPolicyDec func test_link_activated_matches() async throws { guard let action = try await webNavigationSimulator.simulateNavigation( withHTML: .simpleLink, - simulateLinkClickElementId: "link") else { + simulateAction: .click("link")) else { XCTFail("No navigation action captured") return } @@ -30,7 +30,7 @@ final class LinkActivatedWebViewPolicyDecisionHandlerTests: BaseWebViewPolicyDec func test_handling_matching_result_cancels_web_navigation() async throws { guard let action = try await webNavigationSimulator.simulateNavigation( withHTML: .simpleLink, - simulateLinkClickElementId: "link") else { + simulateAction: .click("link")) else { XCTFail("No navigation action captured") return } diff --git a/Tests/Turbo/WebViewPolicy/NavigationPolicyHTML.swift b/Tests/Turbo/WebViewPolicy/NavigationPolicyHTML.swift index fbe36a06..638a6dbd 100644 --- a/Tests/Turbo/WebViewPolicy/NavigationPolicyHTML.swift +++ b/Tests/Turbo/WebViewPolicy/NavigationPolicyHTML.swift @@ -31,6 +31,35 @@ extension String { """ + /// A link to an `about:` URL, which should never be routed externally. + static var aboutLink = """ + + + About Link + + + """ + + /// A link to a `data:` URL, which should never be routed externally. + static var dataLink = """ + + + Data Link + + + """ + + /// A form that submits to an external URL. + static var externalForm = """ + + +
+ +
+ + + """ + /// A JavaScript-initiated reload via a button click. static var reload = """ diff --git a/Tests/Turbo/WebViewPolicy/NewWindowWebViewPolicyDecisionHandlerTests.swift b/Tests/Turbo/WebViewPolicy/NewWindowWebViewPolicyDecisionHandlerTests.swift index ad67aeb8..041fb6e9 100644 --- a/Tests/Turbo/WebViewPolicy/NewWindowWebViewPolicyDecisionHandlerTests.swift +++ b/Tests/Turbo/WebViewPolicy/NewWindowWebViewPolicyDecisionHandlerTests.swift @@ -14,7 +14,7 @@ final class NewWindowWebViewPolicyDecisionHandlerTests: BaseWebViewPolicyDecisio func test_target_blank_matches() async throws { guard let action = try await webNavigationSimulator.simulateNavigation( withHTML: .targetBlank, - simulateLinkClickElementId: "externalLink") else { + simulateAction: .click("externalLink")) else { XCTFail("No navigation action captured") return } @@ -30,7 +30,7 @@ final class NewWindowWebViewPolicyDecisionHandlerTests: BaseWebViewPolicyDecisio func test_handling_matching_result_cancels_web_navigation_and_routes_internally() async throws { guard let action = try await webNavigationSimulator.simulateNavigation( withHTML: .targetBlank, - simulateLinkClickElementId: "externalLink") else { + simulateAction: .click("externalLink")) else { XCTFail("No navigation action captured") return } diff --git a/Tests/Turbo/WebViewPolicy/ReloadWebViewPolicyDecisionHandlerTests.swift b/Tests/Turbo/WebViewPolicy/ReloadWebViewPolicyDecisionHandlerTests.swift index 71d1c12c..d7db3e9e 100644 --- a/Tests/Turbo/WebViewPolicy/ReloadWebViewPolicyDecisionHandlerTests.swift +++ b/Tests/Turbo/WebViewPolicy/ReloadWebViewPolicyDecisionHandlerTests.swift @@ -14,7 +14,7 @@ final class ReloadWebViewPolicyDecisionHandlerTests: BaseWebViewPolicyDecisionHa func test_reload_matches() async throws { guard let action = try await webNavigationSimulator.simulateNavigation( withHTML: .reload, - simulateLinkClickElementId: "reloadButton") else { + simulateAction: .click("reloadButton")) else { XCTFail("No navigation action captured") return } @@ -30,7 +30,7 @@ final class ReloadWebViewPolicyDecisionHandlerTests: BaseWebViewPolicyDecisionHa func test_handling_matching_result_cancels_web_navigation_and_reloads() async throws { guard let action = try await webNavigationSimulator.simulateNavigation( withHTML: .reload, - simulateLinkClickElementId: "reloadButton") else { + simulateAction: .click("reloadButton")) else { XCTFail("No navigation action captured") return } diff --git a/Tests/Turbo/WebViewPolicy/WebViewNavigationSimulator.swift b/Tests/Turbo/WebViewPolicy/WebViewNavigationSimulator.swift deleted file mode 100644 index 8eddc3fe..00000000 --- a/Tests/Turbo/WebViewPolicy/WebViewNavigationSimulator.swift +++ /dev/null @@ -1,74 +0,0 @@ -import Foundation -import XCTest -@preconcurrency import WebKit - -@MainActor -class WebViewNavigationSimulator: NSObject, WKNavigationDelegate { - var capturedNavigationAction: WKNavigationAction? - var simulateLinkClickElementId: String? - - private var didSimulateLinkClick: Bool = false - private var continuation: CheckedContinuation? - - let webView: WKWebView - - override init() { - webView = WKWebView() - super.init() - webView.navigationDelegate = self - } - - /// Loads the given HTML into the web view and awaits the resulting navigation action. - /// - Parameters: - /// - html: The HTML content to load. - /// - simulateLinkClickElementId: If provided, once the page loads, a simulated click will be triggered on the element with this ID. - /// - Returns: The captured `WKNavigationAction`. - func simulateNavigation(withHTML html: String, simulateLinkClickElementId: String? = nil) async throws -> WKNavigationAction? { - self.simulateLinkClickElementId = simulateLinkClickElementId - webView.loadHTMLString(html, baseURL: nil) - return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - self.continuation = continuation - } - } - - // MARK: - WKNavigationDelegate - - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - guard let elementId = simulateLinkClickElementId else { return } - didSimulateLinkClick = true - let js = "document.getElementById('\(elementId)').click();" - webView.evaluateJavaScript(js) { [weak self] (_, error) in - if let error = error { - self?.continuation?.resume(throwing: error) - self?.continuation = nil - } - } - } - - func webView(_ webView: WKWebView, - decidePolicyFor navigationAction: WKNavigationAction, - decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { - capturedNavigationAction = navigationAction - - // When there is no simulated click, or after a simulated click is performed, resume the continuation. - let shouldResumeContinuation = simulateLinkClickElementId == nil || didSimulateLinkClick - - if shouldResumeContinuation { - // Cancel the actual navigation — we only need the WKNavigationAction object. - // Allowing it would let WKWebView navigate to external URLs (e.g., https://example.com), - // blocking the shared WKProcessPool and stalling subsequent tests for minutes. - decisionHandler(.cancel) - continuation?.resume(returning: navigationAction) - continuation = nil - } else { - // Allow intermediate navigations (e.g., the initial loadHTMLString page load) - // so the page fully loads and didFinish can trigger the simulated click. - decisionHandler(.allow) - } - } - - func stopLoading() { - webView.stopLoading() - webView.navigationDelegate = nil - } -} diff --git a/Tests/Turbo/WebViewPolicy/WebViewPolicyManagerTests.swift b/Tests/Turbo/WebViewPolicy/WebViewPolicyManagerTests.swift index 2857049b..45686e20 100644 --- a/Tests/Turbo/WebViewPolicy/WebViewPolicyManagerTests.swift +++ b/Tests/Turbo/WebViewPolicy/WebViewPolicyManagerTests.swift @@ -10,7 +10,7 @@ final class WebViewPolicyManagerTests: XCTestCase { ) let url = URL(string: "https://my.app.com/page")! var policyManager: WebViewPolicyManager! - var webNavigationSimulator: WebViewNavigationSimulator! + var webNavigationSimulator: WebViewPolicyNavigationSimulator! var navigator: Navigator! static var navigationAction: WKNavigationAction! @@ -21,10 +21,10 @@ final class WebViewPolicyManagerTests: XCTestCase { // In these tests, the navigation action is not actually evaluated, but we need it as an argument // when calling `decidePolicy` on the handlers. if Self.navigationAction == nil { - webNavigationSimulator = WebViewNavigationSimulator() + webNavigationSimulator = WebViewPolicyNavigationSimulator() Self.navigationAction = try await webNavigationSimulator.simulateNavigation( withHTML: .simpleLink, - simulateLinkClickElementId: "link" + simulateAction: .click("link") )! } } diff --git a/Tests/Turbo/WebViewPolicy/WebViewPolicyNavigationSimulator.swift b/Tests/Turbo/WebViewPolicy/WebViewPolicyNavigationSimulator.swift new file mode 100644 index 00000000..16ae3498 --- /dev/null +++ b/Tests/Turbo/WebViewPolicy/WebViewPolicyNavigationSimulator.swift @@ -0,0 +1,111 @@ +import Foundation +import XCTest +@preconcurrency import WebKit + +@MainActor +class WebViewPolicyNavigationSimulator: NSObject, WKNavigationDelegate { + enum SimulateAction { + case click(String?) + case submit(String?) + + var elementId: String? { + switch self { + case .click(let string), .submit(let string): + return string + } + } + } + + var capturedNavigationAction: WKNavigationAction? + var simulateAction: SimulateAction = .click(nil) + + private var didSimulateInteraction: Bool = false + private var continuation: CheckedContinuation? + + let webView: WKWebView + + override init() { + webView = WKWebView() + super.init() + webView.navigationDelegate = self + } + + /// Loads the given HTML into the web view and awaits the resulting navigation action. + /// - Parameters: + /// - html: The HTML content to load. + /// - simulateAction: The interaction to simulate once the page loads: `.click` or + /// `.submit` on the element with the given ID. Pass a `nil` element ID to await a + /// navigation initiated by the page itself (e.g. via an inline script). + /// - Returns: The captured `WKNavigationAction`. + func simulateNavigation( + withHTML html: String, + simulateAction: SimulateAction + ) async throws -> WKNavigationAction? { + self.simulateAction = simulateAction + webView.loadHTMLString(html, baseURL: nil) + return try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + } + } + + // MARK: - WKNavigationDelegate + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + guard let elementId = simulateAction.elementId else { return } + didSimulateInteraction = true + + let js: String + switch simulateAction { + case .click: + js = "document.getElementById('\(elementId)').click();" + case .submit: + js = "document.getElementById('\(elementId)').submit();" + } + + webView.evaluateJavaScript(js) { [weak self] _, error in + if let error = error { + self?.continuation?.resume(throwing: error) + self?.continuation = nil + } + } + } + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + // When awaiting a navigation initiated by the page itself (no simulated interaction), + // allow the initial about:blank document load produced by `loadHTMLString(_:baseURL:)` + // so the page can load and run its script, then wait for the page-initiated navigation. + if simulateAction.elementId == nil, + navigationAction.navigationType == .other, + navigationAction.request.url?.absoluteString == "about:blank" { + decisionHandler(.allow) + return + } + + capturedNavigationAction = navigationAction + + // When there is no simulated interaction, or after one is performed, resume the continuation. + let shouldResumeContinuation = simulateAction.elementId == nil || didSimulateInteraction + + if shouldResumeContinuation { + // Cancel the actual navigation — we only need the `WKNavigationAction` object. + // Allowing it would let WKWebView navigate to external URLs (e.g., https://example.com), + // blocking the shared WKProcessPool and stalling subsequent tests for minutes. + decisionHandler(.cancel) + continuation?.resume(returning: navigationAction) + continuation = nil + } else { + // Allow intermediate navigations (e.g., the initial loadHTMLString page load) + // so the page fully loads and didFinish can trigger the simulated interaction. + decisionHandler(.allow) + } + } + + func stopLoading() { + webView.stopLoading() + webView.navigationDelegate = nil + } +}