diff --git a/turbo/build.gradle.kts b/turbo/build.gradle.kts index fafdeeff..047e3a91 100644 --- a/turbo/build.gradle.kts +++ b/turbo/build.gradle.kts @@ -113,8 +113,8 @@ dependencies { testImplementation("androidx.arch.core:core-testing:2.2.0") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") testImplementation("org.assertj:assertj-core:3.24.2") - testImplementation("org.robolectric:robolectric:4.9.2") - testImplementation("org.mockito:mockito-core:5.2.0") + testImplementation("org.robolectric:robolectric:4.10.3") + testImplementation("org.mockito:mockito-core:5.4.0") testImplementation("com.nhaarman:mockito-kotlin:1.6.0") testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") testImplementation("junit:junit:4.13.2") diff --git a/turbo/src/main/kotlin/dev/hotwire/turbo/session/TurboSessionNavHostFragment.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/session/TurboSessionNavHostFragment.kt index 1d89b982..be1a5abe 100644 --- a/turbo/src/main/kotlin/dev/hotwire/turbo/session/TurboSessionNavHostFragment.kt +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/session/TurboSessionNavHostFragment.kt @@ -2,8 +2,12 @@ package dev.hotwire.turbo.session import android.content.Context import android.os.Bundle +import androidx.annotation.VisibleForTesting +import androidx.annotation.VisibleForTesting.Companion.PROTECTED import androidx.appcompat.app.AppCompatActivity +import androidx.core.net.toUri import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity import androidx.navigation.fragment.NavHostFragment import androidx.navigation.fragment.findNavController import dev.hotwire.turbo.config.TurboPathConfiguration @@ -12,6 +16,9 @@ import dev.hotwire.turbo.nav.TurboNavGraphBuilder import dev.hotwire.turbo.views.TurboWebView import kotlin.reflect.KClass +internal const val DEEPLINK_EXTRAS_KEY = "android-support-nav:controller:deepLinkExtras" +internal const val LOCATION_KEY = "location" + abstract class TurboSessionNavHostFragment : NavHostFragment() { /** * The name of the [TurboSession] instance, which is helpful for debugging @@ -51,6 +58,7 @@ abstract class TurboSessionNavHostFragment : NavHostFragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) createNewSession() + ensureDeeplinkStartLocationValid(requireActivity()) initControllerGraph() } @@ -105,6 +113,26 @@ abstract class TurboSessionNavHostFragment : NavHostFragment() { get() = childFragmentManager.primaryNavigationFragment as TurboNavDestination? ?: throw IllegalStateException("No current destination found in NavHostFragment") + /** + * Google's Navigation library automatically navigates to deep links provided in the + * Activity's Intent. This exposes a vulnerability for malicious Intents to open an arbitrary + * webpage outside of the app's domain, allowing javascript injection on the page. Ensure + * that deep link intents always match the app's domain. + */ + @VisibleForTesting + internal fun ensureDeeplinkStartLocationValid(activity: FragmentActivity) { + val extrasBundle = activity.intent.extras?.getBundle(DEEPLINK_EXTRAS_KEY) ?: return + val startLocationFromIntent = extrasBundle.getString(LOCATION_KEY) ?: return + + val deepLinkStartUri = startLocationFromIntent.toUri() + val configStartUri = startLocation.toUri() + + if (deepLinkStartUri.host != configStartUri.host) { + extrasBundle.putString(LOCATION_KEY, startLocation) + activity.intent.putExtra(DEEPLINK_EXTRAS_KEY, extrasBundle) + } + } + private fun initControllerGraph() { navController.apply { graph = TurboNavGraphBuilder( diff --git a/turbo/src/test/kotlin/dev/hotwire/turbo/session/TurboSessionNavHostFragmentTest.kt b/turbo/src/test/kotlin/dev/hotwire/turbo/session/TurboSessionNavHostFragmentTest.kt new file mode 100644 index 00000000..0b6049a2 --- /dev/null +++ b/turbo/src/test/kotlin/dev/hotwire/turbo/session/TurboSessionNavHostFragmentTest.kt @@ -0,0 +1,69 @@ +package dev.hotwire.turbo.session + +import android.content.Intent +import android.os.Build +import androidx.appcompat.app.AppCompatActivity +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import dev.hotwire.turbo.BaseUnitTest +import dev.hotwire.turbo.config.TurboPathConfiguration +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.reflect.KClass + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.O]) +class TurboSessionNavHostFragmentTest : BaseUnitTest() { + + private lateinit var activity: AppCompatActivity + private lateinit var host: TestNavHostFragment + + @Before + override fun setup() { + super.setup() + } + + @Test + fun `reverts to config start location when deep link host differs`() { + val extras = bundleOf(LOCATION_KEY to "https://other.com/path") + val intent = Intent().apply { putExtra(DEEPLINK_EXTRAS_KEY, extras) } + activity = Robolectric.buildActivity(TestActivity::class.java, intent).create().get() + + host = TestNavHostFragment() + host.ensureDeeplinkStartLocationValid(activity) + + val resultBundle = activity.intent.getBundleExtra(DEEPLINK_EXTRAS_KEY) + assertThat(resultBundle?.getString(LOCATION_KEY)).isEqualTo("https://example.com/start") + } + + @Test + fun `does not change start location when deep link host matches config`() { + val extras = bundleOf(LOCATION_KEY to "https://example.com/path") + val intent = Intent().apply { putExtra(DEEPLINK_EXTRAS_KEY, extras) } + activity = Robolectric.buildActivity(TestActivity::class.java, intent).create().get() + + host = TestNavHostFragment() + host.ensureDeeplinkStartLocationValid(activity) + + val resultBundle = activity.intent.getBundleExtra(DEEPLINK_EXTRAS_KEY) + assertThat(resultBundle?.getString(LOCATION_KEY)).isEqualTo("https://example.com/path") + } + +} + +class TestActivity : AppCompatActivity() + +class TestNavHostFragment : TurboSessionNavHostFragment() { + override val sessionName = "test" + override val startLocation = "https://example.com/start" + override val pathConfigurationLocation = TurboPathConfiguration.Location( + assetFilePath = "json/test-configuration.json" + ) + override val registeredFragments: List> = emptyList() +} +