Skip to content

Commit

Permalink
Merge pull request #70 from hotwired/cross-origin-redirect
Browse files Browse the repository at this point in the history
Detect cross-origin redirects during visits
  • Loading branch information
svara authored Jan 10, 2025
2 parents aa5da14 + a919f5f commit 0c20bcb
Show file tree
Hide file tree
Showing 10 changed files with 212 additions and 4 deletions.
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

0 comments on commit 0c20bcb

Please sign in to comment.