diff --git a/Source/Turbo/Navigator/Navigator.swift b/Source/Turbo/Navigator/Navigator.swift index 13c5c47..373d295 100644 --- a/Source/Turbo/Navigator/Navigator.swift +++ b/Source/Turbo/Navigator/Navigator.swift @@ -178,6 +178,14 @@ extension Navigator: SessionDelegate { hierarchyController.route(controller: controller, proposal: proposal) } + public func session(_ session: Session, didProposeVisitToCrossOriginRedirect location: URL) { + // Pop the current destination from the backstack since it + // resulted in a visit failure due to a cross-origin redirect. + pop(animated: false) + let decision = delegate.handle(externalURL: location) + open(externalURL: location, decision) + } + public func sessionDidStartFormSubmission(_ session: Session) { if let url = session.topmostVisitable?.visitableURL { delegate.formSubmissionDidStart(to: url) diff --git a/Source/Turbo/Networking/RedirectHandler.swift b/Source/Turbo/Networking/RedirectHandler.swift new file mode 100644 index 0000000..d19c718 --- /dev/null +++ b/Source/Turbo/Networking/RedirectHandler.swift @@ -0,0 +1,68 @@ +import Foundation + +enum RedirectHandlerError: Error { + case requestFailed(Error) + case responseValidationFailed(reason: ResponseValidationFailureReason) + + /// The underlying reason the `.responseValidationFailed` error occurred. + public enum ResponseValidationFailureReason: Sendable { + case missingURL + case invalidResponse + case unacceptableStatusCode(code: Int) + } +} + +struct RedirectHandler { + enum Result { + case noRedirect + case sameOriginRedirect(URL) + case crossOriginRedirect(URL) + } + + func resolve(location: URL) async throws -> Result { + do { + let request = URLRequest(url: location) + let (_, response) = try await URLSession.shared.data(for: request) + let httpResponse = try validateResponse(response) + + guard let responseUrl = httpResponse.url else { + throw RedirectHandlerError.responseValidationFailed(reason: .missingURL) + } + + let isRedirect = location != responseUrl + let redirectIsCrossOrigin = isRedirect && location.host != responseUrl.host + + guard isRedirect else { + return .noRedirect + } + + if redirectIsCrossOrigin { + return .crossOriginRedirect(responseUrl) + } + + return .sameOriginRedirect(responseUrl) + } catch let error as RedirectHandlerError { + throw error + } catch { + throw RedirectHandlerError.requestFailed(error) + } + } + + private func validateResponse(_ response: URLResponse) throws -> HTTPURLResponse { + guard let httpResponse = response as? HTTPURLResponse else { + throw RedirectHandlerError.responseValidationFailed(reason: .invalidResponse) + } + + guard httpResponse.isSuccessful else { + throw RedirectHandlerError.responseValidationFailed(reason: .unacceptableStatusCode(code: httpResponse.statusCode)) + } + + return httpResponse + } +} + +extension HTTPURLResponse { + public var isSuccessful: Bool { + (200...299).contains(statusCode) + } +} diff --git a/Source/Turbo/Session/Session.swift b/Source/Turbo/Session/Session.swift index 914a0a7..177fa22 100644 --- a/Source/Turbo/Session/Session.swift +++ b/Source/Turbo/Session/Session.swift @@ -358,6 +358,96 @@ extension Session: WebViewDelegate { currentVisit.cancel() visit(currentVisit.visitable) } + + /// Called by the Turbo bridge when a visit request fails with a non-HTTP status code, + /// suggesting it may be the result of a cross-origin redirect visit. + /// + /// Determining a cross-origin redirect is not possible in JavaScript using the Fetch API + /// due to CORS restrictions, so verification is performed on the native side. + /// If a redirect is detected, a cross-origin redirect visit is proposed; otherwise, + /// the visit is failed. + /// + /// - Parameters: + /// - webView: The web view bridge. + /// - location: The original visit location requested. + /// - identifier: A unique identifier for the visit. + func webView(_ webView: WebViewBridge, didFailRequestWithNonHttpStatusToLocation location: URL, identifier: String) { + log("didFailRequestWithNonHttpStatusToLocation", + ["location": location, + "visitIdentifier": identifier] + ) + + Task { + await resolveRedirect(to: location, identifier: identifier) + } + } + + private func resolveRedirect(to location: URL, identifier: String) async { + do { + let result = try await RedirectHandler().resolve(location: location) + switch result { + case .noRedirect: + log("resolveRedirect: no redirect", + ["location": location, + "visitIdentifier": identifier] + ) + await failCurrentVisit( + with: TurboError.http(statusCode: 0), + visitIdentifier: identifier + ) + case .sameOriginRedirect(let url): + // Same-domain redirects are handled by Turbo. + // Handling them here could lead to an infinite loop. + log("resolveRedirect: same domain redirect", + ["location": location, + "redirectLocation": url, + "visitIdentifier": identifier] + ) + await failCurrentVisit( + with: TurboError.http(statusCode: 0), + visitIdentifier: identifier + ) + case .crossOriginRedirect(let url): + await visitProposedToCrossOriginRedirect( + location: location, + redirectLocation: url, + visitIdentifier: identifier + ) + } + } catch { + await failCurrentVisit( + with: error, + visitIdentifier: identifier + ) + } + } + + @MainActor + private func failCurrentVisit(with error: Error, visitIdentifier: String) { + // This is only relevant to `JavaScriptVisit`, as `ColdBootVisit` currently + // doesn't go through the same flow. + guard let visit = currentVisit as? JavaScriptVisit, + visit.identifier == visitIdentifier else { return } + + visit.fail(with: error) + } + + @MainActor + private func visitProposedToCrossOriginRedirect( + location: URL, + redirectLocation: URL, + visitIdentifier: String) { + log("visitProposedToCrossOriginRedirect", + ["location": location, + "redirectLocation": redirectLocation, + "visitIdentifier": visitIdentifier] + ) + + guard let visit = currentVisit as? JavaScriptVisit, + visit.identifier == visitIdentifier else { return } + + delegate?.session(self, didProposeVisitToCrossOriginRedirect: redirectLocation) + } } extension Session: WKNavigationDelegate { diff --git a/Source/Turbo/Session/SessionDelegate.swift b/Source/Turbo/Session/SessionDelegate.swift index 12b215c..a546604 100644 --- a/Source/Turbo/Session/SessionDelegate.swift +++ b/Source/Turbo/Session/SessionDelegate.swift @@ -2,6 +2,7 @@ import UIKit public protocol SessionDelegate: AnyObject { func session(_ session: Session, didProposeVisit proposal: VisitProposal) + func session(_ session: Session, didProposeVisitToCrossOriginRedirect location: URL) func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error) func session(_ session: Session, openExternalURL url: URL) diff --git a/Source/Turbo/Visit/ColdBootVisit.swift b/Source/Turbo/Visit/ColdBootVisit.swift index 01b10ba..7a39dee 100644 --- a/Source/Turbo/Visit/ColdBootVisit.swift +++ b/Source/Turbo/Visit/ColdBootVisit.swift @@ -67,9 +67,29 @@ extension ColdBootVisit: WKNavigationDelegate { if let url = navigationAction.request.url { UIApplication.shared.open(url) } - } else { - decisionHandler(.allow) + return + } + + guard let url = navigationAction.request.url else { + decisionHandler(.cancel) + return } + + let isRedirect = location != url + let redirectIsCrossOrigin = isRedirect && location.host != url.host + + if redirectIsCrossOrigin { + log("Cross-origin redirect detected: \(location) -> \(url).") + decisionHandler(.cancel) + UIApplication.shared.open(url) + return + } + + if isRedirect { + log("Same-origin redirect detected: \(location) -> \(url).") + } + + decisionHandler(.allow) } func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { diff --git a/Source/Turbo/Visit/JavaScriptVisit.swift b/Source/Turbo/Visit/JavaScriptVisit.swift index d2f5d2c..e8eff18 100644 --- a/Source/Turbo/Visit/JavaScriptVisit.swift +++ b/Source/Turbo/Visit/JavaScriptVisit.swift @@ -4,7 +4,7 @@ import Foundation /// All visits are `JavaScriptVisits` except the initial `ColdBootVisit` /// or if a `reload()` is issued. final class JavaScriptVisit: Visit { - private var identifier = "(pending)" + var identifier = "(pending)" init(visitable: Visitable, options: VisitOptions, bridge: WebViewBridge, restorationIdentifier: String?) { super.init(visitable: visitable, options: options, bridge: bridge) diff --git a/Source/Turbo/WebView/ScriptMessage.swift b/Source/Turbo/WebView/ScriptMessage.swift index 48d3c3a..a6bf49b 100644 --- a/Source/Turbo/WebView/ScriptMessage.swift +++ b/Source/Turbo/WebView/ScriptMessage.swift @@ -58,6 +58,7 @@ extension ScriptMessage { case visitRequestStarted case visitRequestCompleted case visitRequestFailed + case visitRequestFailedWithNonHttpStatusCode case visitRequestFinished case visitRendered case visitCompleted diff --git a/Source/Turbo/WebView/WebViewBridge.swift b/Source/Turbo/WebView/WebViewBridge.swift index 9807faf..215a997 100644 --- a/Source/Turbo/WebView/WebViewBridge.swift +++ b/Source/Turbo/WebView/WebViewBridge.swift @@ -7,6 +7,7 @@ protocol WebViewDelegate: AnyObject { func webView(_ webView: WebViewBridge, didFinishFormSubmissionToLocation location: URL) func webView(_ webView: WebViewBridge, didFailInitialPageLoadWithError: Error) func webView(_ webView: WebViewBridge, didFailJavaScriptEvaluationWithError error: Error) + func webView(_ webView: WebViewBridge, didFailRequestWithNonHttpStatusToLocation location: URL, identifier: String) } protocol WebViewPageLoadDelegate: AnyObject { @@ -121,6 +122,8 @@ extension WebViewBridge: ScriptMessageHandlerDelegate { delegate?.webViewDidInvalidatePage(self) case .visitProposed: delegate?.webView(self, didProposeVisitToLocation: message.location!, options: message.options!) + case .visitRequestFailedWithNonHttpStatusCode: + delegate?.webView(self, didFailRequestWithNonHttpStatusToLocation: message.location!, identifier: message.identifier!) case .visitProposalScrollingToAnchor: break case .visitProposalRefreshingPage: diff --git a/Source/Turbo/WebView/turbo.js b/Source/Turbo/WebView/turbo.js index d8c2f4e..f3c6293 100644 --- a/Source/Turbo/WebView/turbo.js +++ b/Source/Turbo/WebView/turbo.js @@ -142,7 +142,17 @@ } visitRequestFailedWithStatusCode(visit, statusCode) { - this.postMessage("visitRequestFailed", { identifier: visit.identifier, statusCode: statusCode }) + const location = visit.location.toString() + + // Non-HTTP status codes are sent by Turbo for network failures, including + // cross-origin fetch redirect attempts. For non-HTTP status codes, pass to + // the native side to determine whether a cross-origin redirect visit should + // be proposed. + if (statusCode <= 0) { + this.postMessage("visitRequestFailedWithNonHttpStatusCode", { location: location, identifier: visit.identifier }) + } else { + this.postMessage("visitRequestFailed", { location: location, identifier: visit.identifier, statusCode: statusCode }) + } } visitRequestFinished(visit) { diff --git a/Tests/Turbo/Test.swift b/Tests/Turbo/Test.swift index 9d6f407..c5ee203 100644 --- a/Tests/Turbo/Test.swift +++ b/Tests/Turbo/Test.swift @@ -46,6 +46,8 @@ class TestSessionDelegate: NSObject, SessionDelegate { var failedRequestError: Error? = nil var sessionDidFailRequestCalled = false { didSet { didChange?() }} var sessionDidProposeVisitCalled = false + var sessionDidProposeVisitToCrossOriginRedirectWasCalled = false + var sessionDidProposeVisitToCrossOriginRedirectLocation: URL? var didChange: (() -> Void)? @@ -75,6 +77,11 @@ class TestSessionDelegate: NSObject, SessionDelegate { func session(_ session: Session, didProposeVisit proposal: VisitProposal) { sessionDidProposeVisitCalled = true } + + func session(_ session: Session, didProposeVisitToCrossOriginRedirect location: URL) { + sessionDidProposeVisitToCrossOriginRedirectWasCalled = true + sessionDidProposeVisitToCrossOriginRedirectLocation = location + } } class TestVisitDelegate {