Fix concurrent Cloudflare challenges each opening a WebView

Closes #3858

Co-authored-by: tigeryu8900 <tigeryu8900@gmail.com>
This commit is contained in:
AntsyLich
2026-10-13 01:51:35 +06:00
co-authored by tigeryu8900
parent f0f1e4de5a
commit 5a5ff22c37
3 changed files with 133 additions and 45 deletions
+1
View File
@@ -46,6 +46,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 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 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 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 ## [v0.21.1] - 2026-10-12
### Fixed ### Fixed
@@ -10,13 +10,15 @@ import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import android.widget.Toast import android.widget.Toast
import androidx.core.content.ContextCompat 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.network.AndroidCookieJar
import eu.kanade.tachiyomi.util.system.isOutdated import eu.kanade.tachiyomi.util.system.isOutdated
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import logcat.LogPriority import logcat.LogPriority
import okhttp3.Cookie import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
@@ -24,7 +26,11 @@ import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import java.io.IOException import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch import java.util.concurrent.CountDownLatch
import java.util.concurrent.locks.ReentrantReadWriteLock
import kotlin.concurrent.read
import kotlin.concurrent.write
class CloudflareInterceptor( class CloudflareInterceptor(
private val context: Context, private val context: Context,
@@ -35,6 +41,18 @@ class CloudflareInterceptor(
private val executor = ContextCompat.getMainExecutor(context) 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<String, ReentrantReadWriteLock>()
private val listenerScript = """
addEventListener("message", ({data}) => {
if (data?.source === "cloudflare-challenge") {
mihon?.postMessage(data.event);
}
})
""".trimIndent()
override fun shouldIntercept(response: Response): Boolean { override fun shouldIntercept(response: Response): Boolean {
// Check if Cloudflare anti-bot is on // Check if Cloudflare anti-bot is on
// Checking the cf-mitigated header is the official way to detect a Cloudflare challenge: // 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 return response.header("cf-mitigated") == "challenge" && response.header("Server") in SERVER_CHECK
} }
override fun intercept( override fun intercept(chain: Interceptor.Chain): Response {
chain: Interceptor.Chain, val request = chain.request()
request: Request, val lock = hostLocks.computeIfAbsent(request.url.host) { ReentrantReadWriteLock() }
response: Response,
): Response { 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 { try {
response.close() response.close()
cookieManager.remove(request.url, COOKIE_NAMES, 0) cookieManager.remove(request.url, COOKIE_NAMES, 0)
val oldCookie = cookieManager.get(request.url) resolveWithWebView(request, clearance)
.firstOrNull { it.name == "cf_clearance" }
resolveWithWebView(request, oldCookie)
return chain.proceed(request)
} }
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that // Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
// we don't crash the entire app // we don't crash the entire app
@@ -66,15 +115,18 @@ class CloudflareInterceptor(
} }
@SuppressLint("SetJavaScriptEnabled") @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 // We need to lock this thread until the WebView finds the challenge solution url, because
// OkHttp doesn't support asynchronous interceptors. // OkHttp doesn't support asynchronous interceptors.
val latch = CountDownLatch(1) val latch = CountDownLatch(1)
var webview: WebView? = null var webview: WebView? = null
var listenerScriptHandler: ScriptHandler? = null
val useIsolatedWorld = WebViewFeature.isFeatureSupported(WebViewFeature.JS_INJECTION_IN_FRAME_AND_WORLD)
var challengeFound = false var challengeFound = false
var cloudflareBypassed = false var cloudflareBypassed = false
var challengeFailed = false
var isWebViewOutdated = false var isWebViewOutdated = false
var isRenderProcessGone = false var isRenderProcessGone = false
@@ -92,27 +144,45 @@ class CloudflareInterceptor(
executor.execute { executor.execute {
webview = createWebView(originalRequest) webview = createWebView(originalRequest)
webview.addJavascriptInterface( fun onChallengeEvent(event: String) {
object { when (event) {
@Suppress("unused") // The challenge cannot be solved non-interactively, abort.
@JavascriptInterface "interactiveBegin" -> latch.countDown()
fun interactiveDetected() { "fail" -> {
// The challenge cannot be solved non-interactively, abort. challengeFailed = true
latch.countDown() 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() { webview.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView, url: String) { override fun onPageFinished(view: WebView, url: String) {
fun isCloudFlareBypassed(): Boolean { if (!challengeFailed && isCleared(originalRequest.url, originalClearance)) {
return cookieManager.get(origRequestUrl.toHttpUrl())
.firstOrNull { it.name == "cf_clearance" }
.let { it != null && it != oldCookie }
}
if (isCloudFlareBypassed()) {
cloudflareBypassed = true cloudflareBypassed = true
latch.countDown() latch.countDown()
} }
@@ -121,18 +191,8 @@ class CloudflareInterceptor(
if (!challengeFound) { if (!challengeFound) {
// The first request didn't return the challenge, abort. // The first request didn't return the challenge, abort.
latch.countDown() latch.countDown()
} else { } else if (!useIsolatedWorld) {
// Listen for an interactiveBegin event view.evaluateJavascript(listenerScript, null)
view.evaluateJavascript(
"""
addEventListener("message", ({data}) => {
if (data?.source === "cloudflare-challenge" && data?.event === "interactiveBegin") {
mihon.interactiveDetected();
}
})
""".trimIndent(),
null,
)
} }
} }
} }
@@ -177,13 +237,27 @@ class CloudflareInterceptor(
} }
webview?.run { webview?.run {
if (!isRenderProcessGone) stopLoading() if (!isRenderProcessGone) {
if (useIsolatedWorld) {
WebViewCompat.removeWebMessageListener(
this,
WebViewCompat.getExecutionWorld(this, "mihon"),
"mihon",
)
} else {
removeJavascriptInterface("mihon")
}
listenerScriptHandler?.remove()
stopLoading()
}
destroy() destroy()
} }
} }
// Throw exception if we failed to bypass Cloudflare // Throw exception if we failed to bypass Cloudflare
if (!cloudflareBypassed) { 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 // Prompt user to update WebView if it seems too outdated
if (isWebViewOutdated) { if (isWebViewOutdated) {
context.toast(MR.strings.information_webview_outdated, Toast.LENGTH_LONG) 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 SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare")
private val COOKIE_NAMES = listOf("cf_clearance") private val COOKIE_NAMES = listOf("cf_clearance")
@@ -59,15 +59,23 @@ abstract class WebViewInterceptor(
return response 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)) { if (!WebViewUtil.supportsWebView(context)) {
scope.launch(Dispatchers.Main) { scope.launch(Dispatchers.Main) {
context.toast(MR.strings.information_webview_required, Toast.LENGTH_LONG) context.toast(MR.strings.information_webview_required, Toast.LENGTH_LONG)
} }
return response return false
} }
initWebView initWebView
return true
return intercept(chain, request, response)
} }
fun parseHeaders(headers: Headers): Map<String, String> { fun parseHeaders(headers: Headers): Map<String, String> {