Skip to content
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

Detect cross-origin visit request attempts #325

Merged
merged 2 commits into from
Mar 28, 2024
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
53 changes: 39 additions & 14 deletions turbo/src/main/assets/js/turbo_bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,18 +98,18 @@
// Adapter interface

visitProposedToLocation(location, options) {
if (window.Turbo && Turbo.navigator.locationWithActionIsSamePage(location, options.action)) {
// Scroll to the anchor on the page
TurboSession.visitProposalScrollingToAnchor(location.toString(), JSON.stringify(options))
Turbo.navigator.view.scrollToAnchorFromLocation(location)
} else if (window.Turbo && Turbo.navigator.location?.href === location.href) {
// Refresh the page without native proposal
TurboSession.visitProposalRefreshingPage(location.toString(), JSON.stringify(options))
this.visitLocationWithOptionsAndRestorationIdentifier(location, JSON.stringify(options), Turbo.navigator.restorationIdentifier)
} else {
// Propose the visit
TurboSession.visitProposedToLocation(location.toString(), JSON.stringify(options))
}
if (window.Turbo && Turbo.navigator.locationWithActionIsSamePage(location, options.action)) {
// Scroll to the anchor on the page
TurboSession.visitProposalScrollingToAnchor(location.toString(), JSON.stringify(options))
Turbo.navigator.view.scrollToAnchorFromLocation(location)
} else if (window.Turbo && Turbo.navigator.location?.href === location.href) {
// Refresh the page without native proposal
TurboSession.visitProposalRefreshingPage(location.toString(), JSON.stringify(options))
this.visitLocationWithOptionsAndRestorationIdentifier(location, JSON.stringify(options), Turbo.navigator.restorationIdentifier)
} else {
// Propose the visit
TurboSession.visitProposedToLocation(location.toString(), JSON.stringify(options))
}
}

// Turbolinks 5
Expand All @@ -134,8 +134,18 @@
this.loadResponseForVisitWithIdentifier(visit.identifier)
}

visitRequestFailedWithStatusCode(visit, statusCode) {
TurboSession.visitRequestFailedWithStatusCode(visit.identifier, visit.hasCachedSnapshot(), statusCode)
async visitRequestFailedWithStatusCode(visit, statusCode) {
// Turbo does not permit cross-origin fetch redirect attempts and
// they'll lead to a visit request failure. Attempt to see if the
// visit request failure was due to a cross-origin redirect.
const redirect = await this.fetchFailedRequestCrossOriginRedirect(visit, statusCode)
const location = visit.location.toString()

if (redirect != null) {
TurboSession.visitProposedToCrossOriginRedirect(location, redirect.toString(), visit.identifier)
} else {
TurboSession.visitRequestFailedWithStatusCode(location, visit.identifier, visit.hasCachedSnapshot(), statusCode)
}
}

visitRequestFinished(visit) {
Expand Down Expand Up @@ -168,6 +178,21 @@

// Private

async fetchFailedRequestCrossOriginRedirect(visit, statusCode) {
// Non-HTTP status codes are sent by Turbo for network
// failures, including cross-origin fetch redirect attempts.
if (statusCode <= 0) {
try {
const response = await fetch(visit.location, { redirect: "follow" })
if (response.url != null && response.url.origin != visit.location.origin) {
return response.url
}
} catch {}
}

return null
}

afterNextRepaint(callback) {
if (document.hidden) {
callback()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.whenStateAtLeast
import dev.hotwire.turbo.config.pullToRefreshEnabled
import dev.hotwire.turbo.errors.TurboVisitError
import dev.hotwire.turbo.fragments.TurboWebFragmentCallback
import dev.hotwire.turbo.nav.TurboNavDestination
import dev.hotwire.turbo.nav.TurboNavigator
Expand All @@ -21,7 +22,6 @@ import dev.hotwire.turbo.views.TurboView
import dev.hotwire.turbo.views.TurboWebView
import dev.hotwire.turbo.visit.TurboVisit
import dev.hotwire.turbo.visit.TurboVisitAction
import dev.hotwire.turbo.errors.TurboVisitError
import dev.hotwire.turbo.visit.TurboVisitOptions
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
Expand Down Expand Up @@ -233,6 +233,13 @@ internal class TurboWebFragmentDelegate(
navigator.navigate(location, options)
}

override fun visitProposedToCrossOriginRedirect(location: String) {
// Pop the current destination from the backstack since it
// resulted in a visit failure due to a cross-origin redirect.
navigator.navigateBack()
navigator.navigate(location, TurboVisitOptions())
}

override fun visitNavDestination(): TurboNavDestination {
return navDestination
}
Expand Down
45 changes: 37 additions & 8 deletions turbo/src/main/kotlin/dev/hotwire/turbo/session/TurboSession.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import androidx.lifecycle.lifecycleScope
import androidx.webkit.WebResourceErrorCompat
import androidx.webkit.WebViewClientCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature.*
import androidx.webkit.WebViewFeature.VISUAL_STATE_CALLBACK
import androidx.webkit.WebViewFeature.isFeatureSupported
import dev.hotwire.turbo.config.TurboPathConfiguration
import dev.hotwire.turbo.config.screenshotsEnabled
import dev.hotwire.turbo.delegates.TurboFileChooserDelegate
Expand All @@ -21,12 +22,14 @@ import dev.hotwire.turbo.errors.WebError
import dev.hotwire.turbo.errors.WebSslError
import dev.hotwire.turbo.http.*
import dev.hotwire.turbo.nav.TurboNavDestination
import dev.hotwire.turbo.util.*
import dev.hotwire.turbo.util.isHttpGetRequest
import dev.hotwire.turbo.util.logEvent
import dev.hotwire.turbo.util.runOnUiThread
import dev.hotwire.turbo.util.toJson
import dev.hotwire.turbo.views.TurboWebView
import dev.hotwire.turbo.visit.TurboVisit
import dev.hotwire.turbo.visit.TurboVisitAction
import dev.hotwire.turbo.visit.TurboVisitOptions
import kotlinx.coroutines.*
import java.util.*

/**
Expand Down Expand Up @@ -197,6 +200,33 @@ class TurboSession internal constructor(
callback { it.visitProposedToLocation(location, options) }
}

/**
* Called by Turbo bridge when a cross-origin redirect visit is proposed.
*
* Warning: This method is public so it can be used as a Javascript Interface.
* You should never call this directly as it could lead to unintended behavior.
*
* @param location The original visit location requested.
* @param redirectLocation The cross-origin redirect location.
* @param visitIdentifier A unique identifier for the visit.
*/
@JavascriptInterface
fun visitProposedToCrossOriginRedirect(
location: String,
redirectLocation: String,
visitIdentifier: String
) {
logEvent("visitProposedToCrossOriginRedirect",
"location" to location,
"redirectLocation" to redirectLocation,
"visitIdentifier" to visitIdentifier
)

if (visitIdentifier == currentVisit?.identifier) {
callback { it.visitProposedToCrossOriginRedirect(redirectLocation) }
}
}

/**
* Called by Turbo bridge when a new visit proposal will refresh the
* current page.
Expand Down Expand Up @@ -284,20 +314,19 @@ class TurboSession internal constructor(
* @param statusCode The HTTP status code that caused the failure.
*/
@JavascriptInterface
fun visitRequestFailedWithStatusCode(visitIdentifier: String, visitHasCachedSnapshot: Boolean, statusCode: Int) {
fun visitRequestFailedWithStatusCode(location: String, visitIdentifier: String, visitHasCachedSnapshot: Boolean, statusCode: Int) {
val visitError = HttpError.from(statusCode)

logEvent(
"visitRequestFailedWithStatusCode",
"location" to location,
"visitIdentifier" to visitIdentifier,
"visitHasCachedSnapshot" to visitHasCachedSnapshot,
"error" to visitError
)

currentVisit?.let { visit ->
if (visitIdentifier == visit.identifier) {
callback { it.requestFailedWithError(visitHasCachedSnapshot, visitError) }
}
if (visitIdentifier == currentVisit?.identifier) {
callback { it.requestFailedWithError(visitHasCachedSnapshot, visitError) }
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package dev.hotwire.turbo.session

import android.webkit.HttpAuthHandler
import dev.hotwire.turbo.nav.TurboNavDestination
import dev.hotwire.turbo.errors.TurboVisitError
import dev.hotwire.turbo.nav.TurboNavDestination
import dev.hotwire.turbo.visit.TurboVisitOptions

internal interface TurboSessionCallback {
Expand All @@ -19,6 +19,7 @@ internal interface TurboSessionCallback {
fun visitCompleted(completedOffline: Boolean)
fun visitLocationStarted(location: String)
fun visitProposedToLocation(location: String, options: TurboVisitOptions)
fun visitProposedToCrossOriginRedirect(location: String)
fun visitNavDestination(): TurboNavDestination
fun formSubmissionStarted(location: String)
fun formSubmissionFinished(location: String)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,17 @@ class TurboSessionTest {
verify(callback).visitProposedToLocation(newLocation, options)
}

@Test
fun visitProposedToCrossOriginRedirectFiresCallback() {
val location = "${visit.location}/page"
val redirectLocation = "https://example.com/page"

session.currentVisit = visit
session.visitProposedToCrossOriginRedirect(location, redirectLocation, visit.identifier)

verify(callback).visitProposedToCrossOriginRedirect(redirectLocation)
}

@Test
fun visitStartedSavesCurrentVisitIdentifier() {
val visitIdentifier = "12345"
Expand Down Expand Up @@ -105,7 +116,7 @@ class TurboSessionTest {
val visitIdentifier = "12345"

session.currentVisit = visit.copy(identifier = visitIdentifier)
session.visitRequestFailedWithStatusCode(visitIdentifier, true, 500)
session.visitRequestFailedWithStatusCode(visit.location, visitIdentifier, true, 500)

verify(callback).requestFailedWithError(
visitHasCachedSnapshot = true,
Expand Down
Loading