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 redirects during visits #82

Merged
merged 3 commits into from
Jan 8, 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
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
Loading