From 5ca2a39328c8a0fb95e47518eeeeef0a4f7286d6 Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:37:53 +0600 Subject: [PATCH] Fix concurrent Cloudflare challenges each opening a WebView Closes #3858 Co-authored-by: tigeryu8900 (cherry picked from commit 5a5ff22c375b77a7557e4fcddf6e377ef94f8b9b) --- CHANGELOG.md | 1 + .../interceptor/CloudflareInterceptor.kt | 163 +++++++++++++----- .../network/interceptor/WebViewInterceptor.kt | 14 +- 3 files changed, 133 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a20d5938..da5b083f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co - Fixed a crash when installing an extension on a device where the system installer can't ask for confirmation ([@AntsyLich](https://github.com/AntsyLich)) - Fixed a crash when tapping in the long strip reader as it closed ([@AntsyLich](https://github.com/AntsyLich)) - Fixed chapters downloaded before v0.21.0 showing as not downloaded when their name started with a space or dot, was long, or had a non-ASCII scanlator ([@AntsyLich](https://github.com/AntsyLich)) +- Fixed slowdowns and running out of memory when many requests to a site hit a Cloudflare challenge at once ([@tigeryu8900](https://github.com/tigeryu8900), [@AntsyLich](https://github.com/AntsyLich)) ## [v0.21.1] - 2026-10-12 ### Fixed diff --git a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt index e6ff19242..4f5576715 100644 --- a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt +++ b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt @@ -10,13 +10,15 @@ import android.webkit.WebView import android.webkit.WebViewClient import android.widget.Toast import androidx.core.content.ContextCompat +import androidx.webkit.ScriptHandler +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature import eu.kanade.tachiyomi.network.AndroidCookieJar import eu.kanade.tachiyomi.util.system.isOutdated import eu.kanade.tachiyomi.util.system.toast import kotlinx.coroutines.CoroutineScope import logcat.LogPriority -import okhttp3.Cookie -import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.HttpUrl import okhttp3.Interceptor import okhttp3.Request import okhttp3.Response @@ -24,7 +26,11 @@ import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.util.system.logcat import tachiyomi.i18n.MR import java.io.IOException +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CountDownLatch +import java.util.concurrent.locks.ReentrantReadWriteLock +import kotlin.concurrent.read +import kotlin.concurrent.write class CloudflareInterceptor( private val context: Context, @@ -35,6 +41,18 @@ class CloudflareInterceptor( private val executor = ContextCompat.getMainExecutor(context) + // Requests to a host share its lock while a challenge is solved under it alone, so concurrent requests wait for + // one WebView instead of each opening their own + private val hostLocks = ConcurrentHashMap() + + private val listenerScript = """ + addEventListener("message", ({data}) => { + if (data?.source === "cloudflare-challenge") { + mihon?.postMessage(data.event); + } + }) + """.trimIndent() + override fun shouldIntercept(response: Response): Boolean { // Check if Cloudflare anti-bot is on // Checking the cf-mitigated header is the official way to detect a Cloudflare challenge: @@ -42,19 +60,50 @@ class CloudflareInterceptor( return response.header("cf-mitigated") == "challenge" && response.header("Server") in SERVER_CHECK } - override fun intercept( - chain: Interceptor.Chain, - request: Request, - response: Response, - ): Response { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val lock = hostLocks.computeIfAbsent(request.url.host) { ReentrantReadWriteLock() } + + val (response, clearance) = lock.read { + val response = chain.proceed(request) + if (!shouldIntercept(response)) return response + response to clearance(request.url) + } + + // write releases the read hold of a request made from within another to the same host, like an extension + // refreshing a token, which would otherwise wait on itself + lock.write { + // Another request may have solved the challenge while this one waited + if (isCleared(request.url, clearance)) { + response.close() + } else { + if (!prepareWebView()) return response + solve(request, response, clearance) + } + } + + return chain.proceed(request) + } + + override fun intercept(chain: Interceptor.Chain, request: Request, response: Response): Response { + solve(request, response, clearance(request.url)) + return chain.proceed(request) + } + + private fun clearance(url: HttpUrl): String? { + return cookieManager.get(url).firstOrNull { it.name == "cf_clearance" }?.value + } + + private fun isCleared(url: HttpUrl, previousClearance: String?): Boolean { + val clearance = clearance(url) + return !clearance.isNullOrBlank() && clearance != previousClearance + } + + private fun solve(request: Request, response: Response, clearance: String?) { try { response.close() cookieManager.remove(request.url, COOKIE_NAMES, 0) - val oldCookie = cookieManager.get(request.url) - .firstOrNull { it.name == "cf_clearance" } - resolveWithWebView(request, oldCookie) - - return chain.proceed(request) + resolveWithWebView(request, clearance) } // Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that // we don't crash the entire app @@ -66,15 +115,18 @@ class CloudflareInterceptor( } @SuppressLint("SetJavaScriptEnabled") - private fun resolveWithWebView(originalRequest: Request, oldCookie: Cookie?) { + private fun resolveWithWebView(originalRequest: Request, originalClearance: String?) { // We need to lock this thread until the WebView finds the challenge solution url, because // OkHttp doesn't support asynchronous interceptors. val latch = CountDownLatch(1) var webview: WebView? = null + var listenerScriptHandler: ScriptHandler? = null + val useIsolatedWorld = WebViewFeature.isFeatureSupported(WebViewFeature.JS_INJECTION_IN_FRAME_AND_WORLD) var challengeFound = false var cloudflareBypassed = false + var challengeFailed = false var isWebViewOutdated = false var isRenderProcessGone = false @@ -92,27 +144,45 @@ class CloudflareInterceptor( executor.execute { webview = createWebView(originalRequest) - webview.addJavascriptInterface( - object { - @Suppress("unused") - @JavascriptInterface - fun interactiveDetected() { - // The challenge cannot be solved non-interactively, abort. + fun onChallengeEvent(event: String) { + when (event) { + // The challenge cannot be solved non-interactively, abort. + "interactiveBegin" -> latch.countDown() + "fail" -> { + challengeFailed = true latch.countDown() } - }, - "mihon", - ) + } + } + + if (useIsolatedWorld) { + // An isolated world keeps the bridge out of the page's reach + val world = WebViewCompat.getExecutionWorld(webview, "mihon") + val origins = setOf(originalRequest.url.origin()) + WebViewCompat.addWebMessageListener(webview, "mihon", origins, world) { _, message, _, isMainFrame, _ -> + if (isMainFrame) message.data?.let(::onChallengeEvent) + } + listenerScriptHandler = WebViewCompat.addJavaScriptOnEvent( + webview, + listenerScript, + WebViewCompat.INJECTION_EVENT_DOCUMENT_START, + origins, + world, + ) + } else { + webview.addJavascriptInterface( + object { + @Suppress("unused") + @JavascriptInterface + fun postMessage(event: String) = onChallengeEvent(event) + }, + "mihon", + ) + } webview.webViewClient = object : WebViewClient() { override fun onPageFinished(view: WebView, url: String) { - fun isCloudFlareBypassed(): Boolean { - return cookieManager.get(origRequestUrl.toHttpUrl()) - .firstOrNull { it.name == "cf_clearance" } - .let { it != null && it != oldCookie } - } - - if (isCloudFlareBypassed()) { + if (!challengeFailed && isCleared(originalRequest.url, originalClearance)) { cloudflareBypassed = true latch.countDown() } @@ -121,18 +191,8 @@ class CloudflareInterceptor( if (!challengeFound) { // The first request didn't return the challenge, abort. latch.countDown() - } else { - // Listen for an interactiveBegin event - view.evaluateJavascript( - """ - addEventListener("message", ({data}) => { - if (data?.source === "cloudflare-challenge" && data?.event === "interactiveBegin") { - mihon.interactiveDetected(); - } - }) - """.trimIndent(), - null, - ) + } else if (!useIsolatedWorld) { + view.evaluateJavascript(listenerScript, null) } } } @@ -177,13 +237,27 @@ class CloudflareInterceptor( } webview?.run { - if (!isRenderProcessGone) stopLoading() + if (!isRenderProcessGone) { + if (useIsolatedWorld) { + WebViewCompat.removeWebMessageListener( + this, + WebViewCompat.getExecutionWorld(this, "mihon"), + "mihon", + ) + } else { + removeJavascriptInterface("mihon") + } + listenerScriptHandler?.remove() + stopLoading() + } destroy() } } // Throw exception if we failed to bypass Cloudflare if (!cloudflareBypassed) { + // A clearance from a failed challenge would only be rejected again + cookieManager.remove(originalRequest.url, COOKIE_NAMES, 0) // Prompt user to update WebView if it seems too outdated if (isWebViewOutdated) { context.toast(MR.strings.information_webview_outdated, Toast.LENGTH_LONG) @@ -194,6 +268,11 @@ class CloudflareInterceptor( } } +// An origin rule without a port only matches the scheme's default one +private fun HttpUrl.origin(): String { + return if (port == HttpUrl.defaultPort(scheme)) "$scheme://$host" else "$scheme://$host:$port" +} + private val SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare") private val COOKIE_NAMES = listOf("cf_clearance") diff --git a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/WebViewInterceptor.kt b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/WebViewInterceptor.kt index f505f80d2..92135aa23 100644 --- a/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/WebViewInterceptor.kt +++ b/core/common/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/WebViewInterceptor.kt @@ -59,15 +59,23 @@ abstract class WebViewInterceptor( return response } + if (!prepareWebView()) return response + + return intercept(chain, request, response) + } + + /** + * Returns whether a WebView can be used, initializing it if so. + */ + protected fun prepareWebView(): Boolean { if (!WebViewUtil.supportsWebView(context)) { scope.launch(Dispatchers.Main) { context.toast(MR.strings.information_webview_required, Toast.LENGTH_LONG) } - return response + return false } initWebView - - return intercept(chain, request, response) + return true } fun parseHeaders(headers: Headers): Map {