mirror of
https://github.com/mihonapp/mihon.git
synced 2026-10-16 13:48:09 -07:00
Fix concurrent Cloudflare challenges each opening a WebView
Closes #3858
Co-authored-by: tigeryu8900 <tigeryu8900@gmail.com>
(cherry picked from commit 5a5ff22c37)
This commit is contained in:
@@ -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
|
||||
|
||||
+121
-42
@@ -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<String, ReentrantReadWriteLock>()
|
||||
|
||||
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")
|
||||
|
||||
|
||||
+11
-3
@@ -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<String, String> {
|
||||
|
||||
Reference in New Issue
Block a user