From 0adb40f66101453adf0053243e673757466c33cf Mon Sep 17 00:00:00 2001 From: Infi Date: Sat, 1 Apr 2023 00:09:18 +0200 Subject: [PATCH] feat: handle cloudflare captcha in under attack mode --- app/build.gradle | 4 ++ app/src/main/AndroidManifest.xml | 5 +- .../revolt/activities/WebChallengeActivity.kt | 47 +++++++++++++++++++ .../main/java/chat/revolt/api/RevoltAPI.kt | 9 ++-- .../chat/revolt/api/internals/WebChallenge.kt | 15 ++++++ .../java/chat/revolt/screens/SplashScreen.kt | 26 ++++++++++ .../main/res/layout/activity_webchallenge.xml | 10 ++++ 7 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/chat/revolt/activities/WebChallengeActivity.kt create mode 100644 app/src/main/java/chat/revolt/api/internals/WebChallenge.kt create mode 100644 app/src/main/res/layout/activity_webchallenge.xml diff --git a/app/build.gradle b/app/build.gradle index a832c262..62517be7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -83,6 +83,7 @@ android { jvmTarget = '1.8' } buildFeatures { + viewBinding true compose true } composeOptions { @@ -163,6 +164,9 @@ dependencies { implementation "androidx.datastore:datastore-preferences:1.1.0-alpha01" implementation "androidx.datastore:datastore:1.1.0-alpha01" + // Libraries used for legacy View-based UI + implementation "androidx.constraintlayout:constraintlayout:2.2.0-alpha09" + // JDK Desugaring - polyfill for new Java APIs coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.2' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 76545d94..189201d8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,10 +19,10 @@ + @@ -30,6 +30,9 @@ + \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/activities/WebChallengeActivity.kt b/app/src/main/java/chat/revolt/activities/WebChallengeActivity.kt new file mode 100644 index 00000000..fdc8e30e --- /dev/null +++ b/app/src/main/java/chat/revolt/activities/WebChallengeActivity.kt @@ -0,0 +1,47 @@ +package chat.revolt.activities + +import android.annotation.SuppressLint +import android.os.Bundle +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.appcompat.app.AppCompatActivity +import chat.revolt.api.REVOLT_BASE +import chat.revolt.api.buildUserAgent +import chat.revolt.databinding.ActivityWebchallengeBinding + +private class WebChallengeClient(val pageLoaded: () -> Unit) : WebViewClient() { + @Override + override fun onPageFinished(view: WebView, url: String) { + super.onPageFinished(view, url) + pageLoaded() + } +} + +class WebChallengeActivity : AppCompatActivity() { + private lateinit var binding: ActivityWebchallengeBinding + + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityWebchallengeBinding.inflate(layoutInflater) + setContentView(binding.root) + + binding.webView.settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + userAgentString = buildUserAgent("WebChallenge") + } + + binding.webView.webViewClient = WebChallengeClient { + binding.webView.evaluateJavascript( + "(function() { return document.getElementById('cf-wrapper') != null; })();" + ) { result -> + if (result == "false") { // No challenge + finish() + } + } + } + + binding.webView.loadUrl(REVOLT_BASE) + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/api/RevoltAPI.kt b/app/src/main/java/chat/revolt/api/RevoltAPI.kt index 8dafbca4..94db07b0 100644 --- a/app/src/main/java/chat/revolt/api/RevoltAPI.kt +++ b/app/src/main/java/chat/revolt/api/RevoltAPI.kt @@ -41,6 +41,10 @@ fun asJanuaryProxyUrl(url: String): String { return "$REVOLT_JANUARY/proxy?url=${url}" } +fun buildUserAgent(accessMethod: String = "Ktor"): String { + return "$accessMethod RevoltAndroid/${BuildConfig.VERSION_NAME} (Android ${android.os.Build.VERSION.SDK_INT}; ${android.os.Build.MANUFACTURER} ${android.os.Build.DEVICE})" +} + private const val BACKEND_IS_STABLE = false val RevoltJson = Json { ignoreUnknownKeys = true } @@ -70,10 +74,7 @@ val RevoltHttp = HttpClient(OkHttp) { defaultRequest { url(REVOLT_BASE) - header( - "User-Agent", - "Ktor RevoltAndroid/${BuildConfig.VERSION_NAME} (Android ${android.os.Build.VERSION.SDK_INT}; ${android.os.Build.MANUFACTURER} ${android.os.Build.DEVICE})" - ) + header("User-Agent", buildUserAgent()) } } diff --git a/app/src/main/java/chat/revolt/api/internals/WebChallenge.kt b/app/src/main/java/chat/revolt/api/internals/WebChallenge.kt new file mode 100644 index 00000000..f74f3cb1 --- /dev/null +++ b/app/src/main/java/chat/revolt/api/internals/WebChallenge.kt @@ -0,0 +1,15 @@ +package chat.revolt.api.internals + +import chat.revolt.api.REVOLT_BASE +import chat.revolt.api.RevoltHttp +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText + +object WebChallenge { + suspend fun needsCloudflare(): Boolean { + RevoltHttp.get(REVOLT_BASE).let { + val text = it.bodyAsText() + return text.contains("window._cf_chl_opt") // FIXME Naive, prone to captcha page changing + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/screens/SplashScreen.kt b/app/src/main/java/chat/revolt/screens/SplashScreen.kt index 6453175c..a6c5408a 100644 --- a/app/src/main/java/chat/revolt/screens/SplashScreen.kt +++ b/app/src/main/java/chat/revolt/screens/SplashScreen.kt @@ -2,8 +2,10 @@ package chat.revolt.screens import android.annotation.SuppressLint import android.content.Context +import android.content.Intent import android.net.ConnectivityManager import android.net.NetworkCapabilities +import android.util.Log import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.* @@ -11,6 +13,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -18,7 +21,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.NavController import chat.revolt.R +import chat.revolt.activities.WebChallengeActivity import chat.revolt.api.RevoltAPI +import chat.revolt.api.internals.WebChallenge import chat.revolt.api.settings.GlobalState import chat.revolt.api.settings.SyncedSettings import chat.revolt.components.screens.splash.DisconnectedScreen @@ -70,11 +75,19 @@ class SplashScreenViewModel @Inject constructor( } fun checkLoggedInState() { + Log.d("SplashScreenViewModel", "Checking logged in state") viewModelScope.launch { setIsConnected(hasInternetConnection()) if (!isConnected) return@launch + val needsCloudflare = WebChallenge.needsCloudflare() + + if (needsCloudflare) { + setNavigateTo("webchallenge") + return@launch + } + val token = kvStorage.get("sessionToken") ?: return@launch setNavigateTo("login") val valid = RevoltAPI.checkSessionToken(token) @@ -107,6 +120,8 @@ fun SplashScreen( navController: NavController, viewModel: SplashScreenViewModel = hiltViewModel() ) { + val context = LocalContext.current + if (!viewModel.isConnected) { DisconnectedScreen( onRetry = { @@ -143,6 +158,17 @@ fun SplashScreen( } } } + + "webchallenge" -> { + context.startActivity( + Intent( + context, + WebChallengeActivity::class.java + ) + ) + viewModel.checkLoggedInState() + } + "home" -> { navController.navigate("chat") { popUpTo("splash") { diff --git a/app/src/main/res/layout/activity_webchallenge.xml b/app/src/main/res/layout/activity_webchallenge.xml new file mode 100644 index 00000000..7636ffbd --- /dev/null +++ b/app/src/main/res/layout/activity_webchallenge.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file