Skip to content

Detect cross-origin redirects during visits #70

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Jan 10, 2025
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
8 changes: 8 additions & 0 deletions Source/Turbo/Navigator/Navigator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
68 changes: 68 additions & 0 deletions Source/Turbo/Networking/RedirectHandler.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
90 changes: 90 additions & 0 deletions Source/Turbo/Session/Session.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions Source/Turbo/Session/SessionDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
24 changes: 22 additions & 2 deletions Source/Turbo/Visit/ColdBootVisit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion Source/Turbo/Visit/JavaScriptVisit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions Source/Turbo/WebView/ScriptMessage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ extension ScriptMessage {
case visitRequestStarted
case visitRequestCompleted
case visitRequestFailed
case visitRequestFailedWithNonHttpStatusCode
case visitRequestFinished
case visitRendered
case visitCompleted
Expand Down
3 changes: 3 additions & 0 deletions Source/Turbo/WebView/WebViewBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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:
Expand Down
12 changes: 11 additions & 1 deletion Source/Turbo/WebView/turbo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
7 changes: 7 additions & 0 deletions Tests/Turbo/Test.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)?

Expand Down Expand Up @@ -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 {
Expand Down