Skip to content

Commit

Permalink
Merge pull request #82 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
jayohms authored Jan 8, 2025
2 parents 31c1db3 + 49a299c commit 7c4cf55
Show file tree
Hide file tree
Showing 10 changed files with 284 additions and 57 deletions.
36 changes: 23 additions & 13 deletions core/src/main/assets/js/turbo.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 @@ -135,7 +135,17 @@
}

visitRequestFailedWithStatusCode(visit, statusCode) {
TurboSession.visitRequestFailedWithStatusCode(visit.identifier, visit.hasCachedSnapshot(), 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) {
TurboSession.visitRequestFailedWithNonHttpStatusCode(location, visit.identifier, visit.hasCachedSnapshot())
} else {
TurboSession.visitRequestFailedWithStatusCode(location, visit.identifier, visit.hasCachedSnapshot(), statusCode)
}
}

visitRequestFinished(visit) {
Expand Down
65 changes: 65 additions & 0 deletions core/src/main/kotlin/dev/hotwire/core/turbo/http/HttpRepository.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package dev.hotwire.core.turbo.http

import android.webkit.CookieManager
import dev.hotwire.core.logging.logError
import dev.hotwire.core.turbo.util.dispatcherProvider
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response

internal class HttpRepository {
private val cookieManager = CookieManager.getInstance()

data class HttpRequestResult(
val response: Response,
val redirect: HttpRedirect?
)

data class HttpRedirect(
val location: String,
val isCrossOrigin: Boolean
)

suspend fun fetch(location: String): HttpRequestResult? {
return withContext(dispatcherProvider.io) {
val response = issueRequest(location)

if (response != null) {
// Determine if there was a redirect, based on the final response's request url
val responseUrl = response.request.url
val isRedirect = location != responseUrl.toString()

HttpRequestResult(
response = response,
redirect = if (!isRedirect) null else HttpRedirect(
location = responseUrl.toString(),
isCrossOrigin = location.toHttpUrl().host != responseUrl.host
)
)
} else {
null
}
}
}

private fun issueRequest(location: String): Response? {
return try {
val request = buildRequest(location)
HotwireHttpClient.instance.newCall(request).execute()
} catch (e: Exception) {
logError("httpRequestError", e)
null
}
}

private fun buildRequest(location: String): Request {
val builder = Request.Builder().url(location)

cookieManager.getCookie(location)?.let {
builder.header("Cookie", it)
}

return builder.build()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import dev.hotwire.core.turbo.util.isHttpGetRequest

internal class OfflineWebViewRequestInterceptor(val session: Session) {
private val offlineRequestHandler get() = Hotwire.config.offlineRequestHandler
private val httpRepository get() = session.httpRepository
private val httpRepository get() = session.offlineHttpRepository
private val currentVisit get() = session.currentVisit

fun interceptRequest(request: WebResourceRequest): WebResourceResponse? {
Expand Down
91 changes: 81 additions & 10 deletions core/src/main/kotlin/dev/hotwire/core/turbo/session/Session.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,24 @@ import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature.VISUAL_STATE_CALLBACK
import androidx.webkit.WebViewFeature.isFeatureSupported
import dev.hotwire.core.config.Hotwire
import dev.hotwire.core.logging.logEvent
import dev.hotwire.core.files.delegates.FileChooserDelegate
import dev.hotwire.core.files.delegates.GeolocationPermissionDelegate
import dev.hotwire.core.logging.logEvent
import dev.hotwire.core.turbo.errors.HttpError
import dev.hotwire.core.turbo.errors.LoadError
import dev.hotwire.core.turbo.errors.WebError
import dev.hotwire.core.turbo.errors.WebSslError
import dev.hotwire.core.turbo.http.HotwireHttpClient
import dev.hotwire.core.turbo.http.HttpRepository
import dev.hotwire.core.turbo.offline.*
import dev.hotwire.core.turbo.util.isHttpGetRequest
import dev.hotwire.core.turbo.util.runOnUiThread
import dev.hotwire.core.turbo.util.toJson
import dev.hotwire.core.turbo.webview.HotwireWebView
import dev.hotwire.core.turbo.visit.Visit
import dev.hotwire.core.turbo.visit.VisitAction
import dev.hotwire.core.turbo.visit.VisitOptions
import dev.hotwire.core.turbo.webview.HotwireWebView
import kotlinx.coroutines.launch
import java.util.Date

/**
Expand All @@ -52,8 +54,9 @@ class Session(
internal var visitPending = false
internal var restorationIdentifiers = SparseArray<String>()
internal val context: Context = activity.applicationContext
internal val httpRepository = OfflineHttpRepository(activity.lifecycleScope)
internal val requestInterceptor = OfflineWebViewRequestInterceptor(this)
internal val httpRepository = HttpRepository()
internal val offlineHttpRepository = OfflineHttpRepository(activity.lifecycleScope)
internal val offlineRequestInterceptor = OfflineWebViewRequestInterceptor(this)

// User accessible

Expand Down Expand Up @@ -106,7 +109,7 @@ class Session(
"An offline request handler must be provided to pre-cache $location"
}

httpRepository.preCache(
offlineHttpRepository.preCache(
requestHandler, OfflinePreCacheRequest(
url = location, userAgent = webView.settings.userAgentString
)
Expand Down Expand Up @@ -195,6 +198,22 @@ class Session(
callback { it.visitProposedToLocation(location, options) }
}

private 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 @@ -277,24 +296,76 @@ class Session(
* 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 location of the failed visit.
* @param visitIdentifier A unique identifier for the visit.
* @param visitHasCachedSnapshot Whether the visit has a cached snapshot available.
* @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) }
}
}

/**
* Called by 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 with the Fetch API due to CORS restrictions, so verify on
* the native side. Propose a cross-origin redirect visit if a redirect is found, otherwise
* fail the visit.
*
* 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 visitIdentifier A unique identifier for the visit.
* @param visitHasCachedSnapshot Whether the visit has a cached snapshot available.
*/
@JavascriptInterface
fun visitRequestFailedWithNonHttpStatusCode(
location: String,
visitIdentifier: String,
visitHasCachedSnapshot: Boolean
) {
logEvent("visitRequestFailedWithNonHttpStatusCode",
"location" to location,
"visitIdentifier" to visitIdentifier,
"visitHasCachedSnapshot" to visitHasCachedSnapshot
)

activity.lifecycleScope.launch {
val result = httpRepository.fetch(location)

if (result != null && result.response.isSuccessful &&
result.redirect?.isCrossOrigin == true) {
visitProposedToCrossOriginRedirect(
location = location,
redirectLocation = result.redirect.location,
visitIdentifier = visitIdentifier
)
} else {
visitRequestFailedWithStatusCode(
location = location,
visitIdentifier = visitIdentifier,
visitHasCachedSnapshot = visitHasCachedSnapshot,
statusCode = WebError.Unknown.errorCode
)
}
}
}
Expand Down Expand Up @@ -749,7 +820,7 @@ class Session(
}

override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
return requestInterceptor.interceptRequest(request)
return offlineRequestInterceptor.interceptRequest(request)
}

override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceErrorCompat) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ interface SessionCallback {
fun visitCompleted(completedOffline: Boolean)
fun visitLocationStarted(location: String)
fun visitProposedToLocation(location: String, options: VisitOptions)
fun visitProposedToCrossOriginRedirect(location: String)
fun visitDestination(): VisitDestination
fun formSubmissionStarted(location: String)
fun formSubmissionFinished(location: String)
Expand Down
21 changes: 14 additions & 7 deletions core/src/test/kotlin/dev/hotwire/core/turbo/BaseRepositoryTest.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package dev.hotwire.core.turbo

import dev.hotwire.core.turbo.http.HotwireHttpClient
import dev.hotwire.core.turbo.util.dispatcherProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
Expand All @@ -26,6 +27,7 @@ open class BaseRepositoryTest : BaseUnitTest() {

override fun setup() {
super.setup()
HotwireHttpClient.instance = client()
Dispatchers.setMain(testDispatcher)
dispatcherProvider.io = Dispatchers.Main
server.start()
Expand All @@ -38,27 +40,32 @@ open class BaseRepositoryTest : BaseUnitTest() {
server.shutdown()
}

protected fun client(): OkHttpClient {
return OkHttpClient.Builder()
.dispatcher(Dispatcher(SynchronousExecutorService()))
.build()
}

protected fun baseUrl(): String {
return server.url("/").toString()
}

protected fun enqueueResponse(fileName: String, headers: Map<String, String> = emptyMap()) {
protected fun enqueueResponse(
fileName: String,
responseCode: Int = 200,
headers: Map<String, String> = emptyMap()
) {
val inputStream = loadAsset(fileName)
val source = inputStream.source().buffer()
val mockResponse = MockResponse().apply {
setResponseCode(responseCode)
headers.forEach { addHeader(it.key, it.value) }
setBody(source.readString(StandardCharsets.UTF_8))
}

server.enqueue(mockResponse)
}

private fun client(): OkHttpClient {
return OkHttpClient.Builder()
.dispatcher(Dispatcher(SynchronousExecutorService()))
.build()
}

private fun loadAsset(fileName: String): InputStream {
return javaClass.classLoader?.getResourceAsStream("http-responses/$fileName")
?: throw IllegalStateException("Couldn't load api response file")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ class PathConfigurationRepositoryTest : BaseRepositoryTest() {
override fun setup() {
super.setup()
context = ApplicationProvider.getApplicationContext()
HotwireHttpClient.instance = client()
}

@Test
Expand Down
Loading

0 comments on commit 7c4cf55

Please sign in to comment.