Skip to content
Merged
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
20 changes: 20 additions & 0 deletions Source/Turbo/Navigator/WKUIController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>

public init(invalidSchemes: Set<String> = ["about", "data", "file", "javascript"]) {
self.invalidSchemes = invalidSchemes
}

public func matches(navigationAction: WKNavigationAction,
configuration: Navigator.Configuration) -> Bool {
Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down
29 changes: 29 additions & 0 deletions Tests/Turbo/WebViewPolicy/NavigationPolicyHTML.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,35 @@ extension String {
</html>
"""

/// A link to an `about:` URL, which should never be routed externally.
static var aboutLink = """
<html>
<body>
<a id="link" href="about:blank">About Link</a>
</body>
</html>
"""

/// A link to a `data:` URL, which should never be routed externally.
static var dataLink = """
<html>
<body>
<a id="link" href="data:text/plain,hello">Data Link</a>
</body>
</html>
"""

/// A form that submits to an external URL.
static var externalForm = """
<html>
<body>
<form id="form" action="https://example.com/submit" method="POST">
<input type="hidden" name="x" value="1" />
</form>
</body>
</html>
"""

/// A JavaScript-initiated reload via a button click.
static var reload = """
<html>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down
74 changes: 0 additions & 74 deletions Tests/Turbo/WebViewPolicy/WebViewNavigationSimulator.swift

This file was deleted.

6 changes: 3 additions & 3 deletions Tests/Turbo/WebViewPolicy/WebViewPolicyManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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!

Expand All @@ -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")
)!
}
}
Expand Down
Loading
Loading