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>
This commit is contained in:
@@ -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
|
||||||
|
|||||||
+121
-42
@@ -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")
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -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> {
|
||||||
|
|||||||
Reference in New Issue
Block a user