Skip to content

Commit 0c20bcb

Browse files
authored
Merge pull request #70 from hotwired/cross-origin-redirect
Detect cross-origin redirects during visits
2 parents aa5da14 + a919f5f commit 0c20bcb

10 files changed

+212
-4
lines changed

Source/Turbo/Navigator/Navigator.swift

+8
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,14 @@ extension Navigator: SessionDelegate {
178178
hierarchyController.route(controller: controller, proposal: proposal)
179179
}
180180

181+
public func session(_ session: Session, didProposeVisitToCrossOriginRedirect location: URL) {
182+
// Pop the current destination from the backstack since it
183+
// resulted in a visit failure due to a cross-origin redirect.
184+
pop(animated: false)
185+
let decision = delegate.handle(externalURL: location)
186+
open(externalURL: location, decision)
187+
}
188+
181189
public func sessionDidStartFormSubmission(_ session: Session) {
182190
if let url = session.topmostVisitable?.visitableURL {
183191
delegate.formSubmissionDidStart(to: url)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import Foundation
2+
3+
enum RedirectHandlerError: Error {
4+
case requestFailed(Error)
5+
case responseValidationFailed(reason: ResponseValidationFailureReason)
6+
7+
/// The underlying reason the `.responseValidationFailed` error occurred.
8+
public enum ResponseValidationFailureReason: Sendable {
9+
case missingURL
10+
case invalidResponse
11+
case unacceptableStatusCode(code: Int)
12+
}
13+
}
14+
15+
struct RedirectHandler {
16+
enum Result {
17+
case noRedirect
18+
case sameOriginRedirect(URL)
19+
case crossOriginRedirect(URL)
20+
}
21+
22+
func resolve(location: URL) async throws -> Result {
23+
do {
24+
let request = URLRequest(url: location)
25+
let (_, response) = try await URLSession.shared.data(for: request)
26+
let httpResponse = try validateResponse(response)
27+
28+
guard let responseUrl = httpResponse.url else {
29+
throw RedirectHandlerError.responseValidationFailed(reason: .missingURL)
30+
}
31+
32+
let isRedirect = location != responseUrl
33+
let redirectIsCrossOrigin = isRedirect && location.host != responseUrl.host
34+
35+
guard isRedirect else {
36+
return .noRedirect
37+
}
38+
39+
if redirectIsCrossOrigin {
40+
return .crossOriginRedirect(responseUrl)
41+
}
42+
43+
return .sameOriginRedirect(responseUrl)
44+
} catch let error as RedirectHandlerError {
45+
throw error
46+
} catch {
47+
throw RedirectHandlerError.requestFailed(error)
48+
}
49+
}
50+
51+
private func validateResponse(_ response: URLResponse) throws -> HTTPURLResponse {
52+
guard let httpResponse = response as? HTTPURLResponse else {
53+
throw RedirectHandlerError.responseValidationFailed(reason: .invalidResponse)
54+
}
55+
56+
guard httpResponse.isSuccessful else {
57+
throw RedirectHandlerError.responseValidationFailed(reason: .unacceptableStatusCode(code: httpResponse.statusCode))
58+
}
59+
60+
return httpResponse
61+
}
62+
}
63+
64+
extension HTTPURLResponse {
65+
public var isSuccessful: Bool {
66+
(200...299).contains(statusCode)
67+
}
68+
}

Source/Turbo/Session/Session.swift

+90
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,96 @@ extension Session: WebViewDelegate {
358358
currentVisit.cancel()
359359
visit(currentVisit.visitable)
360360
}
361+
362+
/// Called by the Turbo bridge when a visit request fails with a non-HTTP status code,
363+
/// suggesting it may be the result of a cross-origin redirect visit.
364+
///
365+
/// Determining a cross-origin redirect is not possible in JavaScript using the Fetch API
366+
/// due to CORS restrictions, so verification is performed on the native side.
367+
/// If a redirect is detected, a cross-origin redirect visit is proposed; otherwise,
368+
/// the visit is failed.
369+
///
370+
/// - Parameters:
371+
/// - webView: The web view bridge.
372+
/// - location: The original visit location requested.
373+
/// - identifier: A unique identifier for the visit.
374+
func webView(_ webView: WebViewBridge, didFailRequestWithNonHttpStatusToLocation location: URL, identifier: String) {
375+
log("didFailRequestWithNonHttpStatusToLocation",
376+
["location": location,
377+
"visitIdentifier": identifier]
378+
)
379+
380+
Task {
381+
await resolveRedirect(to: location, identifier: identifier)
382+
}
383+
}
384+
385+
private func resolveRedirect(to location: URL, identifier: String) async {
386+
do {
387+
let result = try await RedirectHandler().resolve(location: location)
388+
switch result {
389+
case .noRedirect:
390+
log("resolveRedirect: no redirect",
391+
["location": location,
392+
"visitIdentifier": identifier]
393+
)
394+
await failCurrentVisit(
395+
with: TurboError.http(statusCode: 0),
396+
visitIdentifier: identifier
397+
)
398+
case .sameOriginRedirect(let url):
399+
// Same-domain redirects are handled by Turbo.
400+
// Handling them here could lead to an infinite loop.
401+
log("resolveRedirect: same domain redirect",
402+
["location": location,
403+
"redirectLocation": url,
404+
"visitIdentifier": identifier]
405+
)
406+
await failCurrentVisit(
407+
with: TurboError.http(statusCode: 0),
408+
visitIdentifier: identifier
409+
)
410+
case .crossOriginRedirect(let url):
411+
await visitProposedToCrossOriginRedirect(
412+
location: location,
413+
redirectLocation: url,
414+
visitIdentifier: identifier
415+
)
416+
}
417+
} catch {
418+
await failCurrentVisit(
419+
with: error,
420+
visitIdentifier: identifier
421+
)
422+
}
423+
}
424+
425+
@MainActor
426+
private func failCurrentVisit(with error: Error, visitIdentifier: String) {
427+
// This is only relevant to `JavaScriptVisit`, as `ColdBootVisit` currently
428+
// doesn't go through the same flow.
429+
guard let visit = currentVisit as? JavaScriptVisit,
430+
visit.identifier == visitIdentifier else { return }
431+
432+
visit.fail(with: error)
433+
}
434+
435+
@MainActor
436+
private func visitProposedToCrossOriginRedirect(
437+
location: URL,
438+
redirectLocation: URL,
439+
visitIdentifier: String) {
440+
log("visitProposedToCrossOriginRedirect",
441+
["location": location,
442+
"redirectLocation": redirectLocation,
443+
"visitIdentifier": visitIdentifier]
444+
)
445+
446+
guard let visit = currentVisit as? JavaScriptVisit,
447+
visit.identifier == visitIdentifier else { return }
448+
449+
delegate?.session(self, didProposeVisitToCrossOriginRedirect: redirectLocation)
450+
}
361451
}
362452

363453
extension Session: WKNavigationDelegate {

Source/Turbo/Session/SessionDelegate.swift

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import UIKit
22

33
public protocol SessionDelegate: AnyObject {
44
func session(_ session: Session, didProposeVisit proposal: VisitProposal)
5+
func session(_ session: Session, didProposeVisitToCrossOriginRedirect location: URL)
56
func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error)
67

78
func session(_ session: Session, openExternalURL url: URL)

Source/Turbo/Visit/ColdBootVisit.swift

+22-2
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,29 @@ extension ColdBootVisit: WKNavigationDelegate {
6767
if let url = navigationAction.request.url {
6868
UIApplication.shared.open(url)
6969
}
70-
} else {
71-
decisionHandler(.allow)
70+
return
71+
}
72+
73+
guard let url = navigationAction.request.url else {
74+
decisionHandler(.cancel)
75+
return
7276
}
77+
78+
let isRedirect = location != url
79+
let redirectIsCrossOrigin = isRedirect && location.host != url.host
80+
81+
if redirectIsCrossOrigin {
82+
log("Cross-origin redirect detected: \(location) -> \(url).")
83+
decisionHandler(.cancel)
84+
UIApplication.shared.open(url)
85+
return
86+
}
87+
88+
if isRedirect {
89+
log("Same-origin redirect detected: \(location) -> \(url).")
90+
}
91+
92+
decisionHandler(.allow)
7393
}
7494

7595
func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {

Source/Turbo/Visit/JavaScriptVisit.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import Foundation
44
/// All visits are `JavaScriptVisits` except the initial `ColdBootVisit`
55
/// or if a `reload()` is issued.
66
final class JavaScriptVisit: Visit {
7-
private var identifier = "(pending)"
7+
var identifier = "(pending)"
88

99
init(visitable: Visitable, options: VisitOptions, bridge: WebViewBridge, restorationIdentifier: String?) {
1010
super.init(visitable: visitable, options: options, bridge: bridge)

Source/Turbo/WebView/ScriptMessage.swift

+1
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ extension ScriptMessage {
5858
case visitRequestStarted
5959
case visitRequestCompleted
6060
case visitRequestFailed
61+
case visitRequestFailedWithNonHttpStatusCode
6162
case visitRequestFinished
6263
case visitRendered
6364
case visitCompleted

Source/Turbo/WebView/WebViewBridge.swift

+3
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ protocol WebViewDelegate: AnyObject {
77
func webView(_ webView: WebViewBridge, didFinishFormSubmissionToLocation location: URL)
88
func webView(_ webView: WebViewBridge, didFailInitialPageLoadWithError: Error)
99
func webView(_ webView: WebViewBridge, didFailJavaScriptEvaluationWithError error: Error)
10+
func webView(_ webView: WebViewBridge, didFailRequestWithNonHttpStatusToLocation location: URL, identifier: String)
1011
}
1112

1213
protocol WebViewPageLoadDelegate: AnyObject {
@@ -121,6 +122,8 @@ extension WebViewBridge: ScriptMessageHandlerDelegate {
121122
delegate?.webViewDidInvalidatePage(self)
122123
case .visitProposed:
123124
delegate?.webView(self, didProposeVisitToLocation: message.location!, options: message.options!)
125+
case .visitRequestFailedWithNonHttpStatusCode:
126+
delegate?.webView(self, didFailRequestWithNonHttpStatusToLocation: message.location!, identifier: message.identifier!)
124127
case .visitProposalScrollingToAnchor:
125128
break
126129
case .visitProposalRefreshingPage:

Source/Turbo/WebView/turbo.js

+11-1
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,17 @@
142142
}
143143

144144
visitRequestFailedWithStatusCode(visit, statusCode) {
145-
this.postMessage("visitRequestFailed", { identifier: visit.identifier, statusCode: statusCode })
145+
const location = visit.location.toString()
146+
147+
// Non-HTTP status codes are sent by Turbo for network failures, including
148+
// cross-origin fetch redirect attempts. For non-HTTP status codes, pass to
149+
// the native side to determine whether a cross-origin redirect visit should
150+
// be proposed.
151+
if (statusCode <= 0) {
152+
this.postMessage("visitRequestFailedWithNonHttpStatusCode", { location: location, identifier: visit.identifier })
153+
} else {
154+
this.postMessage("visitRequestFailed", { location: location, identifier: visit.identifier, statusCode: statusCode })
155+
}
146156
}
147157

148158
visitRequestFinished(visit) {

Tests/Turbo/Test.swift

+7
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ class TestSessionDelegate: NSObject, SessionDelegate {
4646
var failedRequestError: Error? = nil
4747
var sessionDidFailRequestCalled = false { didSet { didChange?() }}
4848
var sessionDidProposeVisitCalled = false
49+
var sessionDidProposeVisitToCrossOriginRedirectWasCalled = false
50+
var sessionDidProposeVisitToCrossOriginRedirectLocation: URL?
4951

5052
var didChange: (() -> Void)?
5153

@@ -75,6 +77,11 @@ class TestSessionDelegate: NSObject, SessionDelegate {
7577
func session(_ session: Session, didProposeVisit proposal: VisitProposal) {
7678
sessionDidProposeVisitCalled = true
7779
}
80+
81+
func session(_ session: Session, didProposeVisitToCrossOriginRedirect location: URL) {
82+
sessionDidProposeVisitToCrossOriginRedirectWasCalled = true
83+
sessionDidProposeVisitToCrossOriginRedirectLocation = location
84+
}
7885
}
7986

8087
class TestVisitDelegate {

0 commit comments

Comments
 (0)