WebView based cloudflare interceptor (#456)

* WebView based cloudflare interceptor

ported https://github.com/vvanglro/cf-clearance to kotlin

* code clean up

* Forgot to commit these

* Get ResolveWithWebView working
1. Make sure to .use all closeable resources
2. Use 10 seconds instead of 1 second for waiting for cloudflare(this was the most probable issue)
3. Use Extension UA when possible
4. Minor cleanup of logging

* rewrite and refactor

Co-authored-by: Syer10 <syer10@users.noreply.github.com>
This commit is contained in:
Aria Moradi
2022-12-04 12:08:54 +03:30
committed by GitHub
parent 5f8e74f017
commit f47dc6b9de
13 changed files with 695 additions and 50 deletions

View File

@@ -18,8 +18,8 @@ allprojects {
repositories {
mavenCentral()
google()
maven("https://jitpack.io")
maven("https://github.com/Suwayomi/Tachidesk-Server/raw/android-jar/")
maven("https://jitpack.io")
}
}

View File

@@ -57,7 +57,7 @@ dependencies {
implementation("com.github.junrar:junrar:7.5.3")
// CloudflareInterceptor
implementation("net.sourceforge.htmlunit:htmlunit:2.65.1")
implementation("com.microsoft.playwright:playwright:1.28.0")
// AES/CBC/PKCS7Padding Cypher provider for zh.copymanga
implementation("org.bouncycastle:bcprov-jdk18on:1.72")

View File

@@ -39,6 +39,7 @@ class NetworkHelper(context: Context) {
.cookieJar(cookieManager)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.callTimeout(2, TimeUnit.MINUTES)
.addInterceptor(UserAgentInterceptor())
if (serverConfig.debugLogsEnabled) {

View File

@@ -62,7 +62,7 @@ suspend fun Call.await(): Response {
object : Callback {
override fun onResponse(call: Call, response: Response) {
if (!response.isSuccessful) {
continuation.resumeWithException(Exception("HTTP error ${response.code}"))
continuation.resumeWithException(HttpException(response.code))
return
}
@@ -94,7 +94,7 @@ fun Call.asObservableSuccess(): Observable<Response> {
.doOnNext { response ->
if (!response.isSuccessful) {
response.close()
throw Exception("HTTP error ${response.code}")
throw HttpException(response.code)
}
}
}
@@ -136,3 +136,5 @@ inline fun <reified T> Response.parseAs(): T {
return json.decodeFromString(responseBody)
}
}
class HttpException(val code: Int) : IllegalStateException("HTTP error $code")

View File

@@ -1,19 +1,24 @@
package eu.kanade.tachiyomi.network.interceptor
import com.gargoylesoftware.htmlunit.BrowserVersion
import com.gargoylesoftware.htmlunit.WebClient
import com.gargoylesoftware.htmlunit.html.HtmlPage
import com.microsoft.playwright.Browser
import com.microsoft.playwright.BrowserType.LaunchOptions
import com.microsoft.playwright.Page
import com.microsoft.playwright.Playwright
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.interceptor.CFClearance.resolveWithWebView
import mu.KotlinLogging
import okhttp3.Cookie
import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import suwayomi.tachidesk.server.ServerConfig
import suwayomi.tachidesk.server.serverConfig
import uy.kohesive.injekt.injectLazy
import java.io.IOException
import kotlin.time.Duration.Companion.seconds
import kotlin.time.DurationUnit
// from TachiWeb-Server
class CloudflareInterceptor : Interceptor {
private val logger = KotlinLogging.logger {}
@@ -25,20 +30,22 @@ class CloudflareInterceptor : Interceptor {
logger.trace { "CloudflareInterceptor is being used." }
val response = chain.proceed(originalRequest)
val originalResponse = chain.proceed(chain.request())
// Check if Cloudflare anti-bot is on
if (response.code != 503 || response.header("Server") !in SERVER_CHECK) {
return response
if (!(originalResponse.code in ERROR_CODES && originalResponse.header("Server") in SERVER_CHECK)) {
return originalResponse
}
logger.debug { "Cloudflare anti-bot is on, CloudflareInterceptor is kicking in..." }
return try {
response.close()
originalResponse.close()
network.cookies.remove(originalRequest.url.toUri())
chain.proceed(resolveChallenge(response))
val request = resolveWithWebView(originalRequest)
chain.proceed(request)
} catch (e: Exception) {
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
// we don't crash the entire app
@@ -46,65 +53,148 @@ class CloudflareInterceptor : Interceptor {
}
}
private fun resolveChallenge(response: Response): Request {
val browserVersion = BrowserVersion.BrowserVersionBuilder(BrowserVersion.BEST_SUPPORTED)
.setUserAgent(response.request.header("User-Agent") ?: BrowserVersion.BEST_SUPPORTED.userAgent)
.build()
val convertedCookies = WebClient(browserVersion).use { webClient ->
webClient.options.isThrowExceptionOnFailingStatusCode = false
webClient.options.isThrowExceptionOnScriptError = false
webClient.getPage<HtmlPage>(response.request.url.toString())
webClient.waitForBackgroundJavaScript(10000)
// Challenge solved, process cookies
webClient.cookieManager.cookies.filter {
// Only include Cloudflare cookies
it.name.startsWith("__cf") || it.name.startsWith("cf_")
}.map {
// Convert cookies -> OkHttp format
Cookie.Builder()
.domain(it.domain.removePrefix("."))
.expiresAt(it.expires?.time ?: Long.MAX_VALUE)
.name(it.name)
.path(it.path)
.value(it.value).apply {
if (it.isHttpOnly) httpOnly()
if (it.isSecure) secure()
}.build()
companion object {
private val ERROR_CODES = listOf(403, 503)
private val SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare")
private val COOKIE_NAMES = listOf("cf_clearance")
}
}
/*
* This class is ported from https://github.com/vvanglro/cf-clearance
* The original code is licensed under Apache 2.0
*/
object CFClearance {
private val logger = KotlinLogging.logger {}
private val network: NetworkHelper by injectLazy()
fun resolveWithWebView(originalRequest: Request): Request {
val url = originalRequest.url.toString()
logger.debug { "resolveWithWebView($url)" }
val cookies = Playwright.create().use { playwright ->
playwright.chromium().launch(
LaunchOptions()
.setHeadless(false)
.apply {
if (serverConfig.socksProxyEnabled) {
setProxy("socks5://${serverConfig.socksProxyHost}:${serverConfig.socksProxyPort}")
}
}
).use { browser ->
val userAgent = originalRequest.header("User-Agent")
if (userAgent != null) {
browser.newContext(Browser.NewContextOptions().setUserAgent(userAgent)).use { browserContext ->
browserContext.newPage().use { getCookies(it, url) }
}
} else {
browser.newPage().use { getCookies(it, url) }
}
}
}
// Copy cookies to cookie store
convertedCookies.forEach {
cookies.groupBy { it.domain }.forEach { (domain, cookies) ->
network.cookies.addAll(
HttpUrl.Builder()
url = HttpUrl.Builder()
.scheme("http")
.host(it.domain)
.host(domain)
.build(),
listOf(it)
cookies = cookies
)
}
// Merge new and existing cookies for this request
// Find the cookies that we need to merge into this request
val convertedForThisRequest = convertedCookies.filter {
it.matches(response.request.url)
val convertedForThisRequest = cookies.filter {
it.matches(originalRequest.url)
}
// Extract cookies from current request
val existingCookies = Cookie.parseAll(
response.request.url,
response.request.headers
originalRequest.url,
originalRequest.headers
)
// Filter out existing values of cookies that we are about to merge in
val filteredExisting = existingCookies.filter { existing ->
convertedForThisRequest.none { converted -> converted.name == existing.name }
}
logger.trace { "Existing cookies" }
logger.trace { existingCookies.joinToString("; ") }
val newCookies = filteredExisting + convertedForThisRequest
return response.request.newBuilder()
.header("Cookie", newCookies.map { it.toString() }.joinToString("; "))
logger.trace { "New cookies" }
logger.trace { newCookies.joinToString("; ") }
return originalRequest.newBuilder()
.header("Cookie", newCookies.joinToString("; ") { "${it.name}=${it.value}" })
.build()
}
companion object {
private val SERVER_CHECK = arrayOf("cloudflare-nginx", "cloudflare")
private val COOKIE_NAMES = listOf("cf_clearance")
private fun getCookies(page: Page, url: String): List<Cookie> {
applyStealthInitScripts(page)
page.navigate(url)
val challengeResolved = waitForChallengeResolve(page)
return if (challengeResolved) {
val cookies = page.context().cookies()
logger.debug {
val userAgent = page.evaluate("() => {return navigator.userAgent}")
"Playwright User-Agent is $userAgent"
}
// Convert PlayWright cookies to OkHttp cookies
cookies.map {
Cookie.Builder()
.domain(it.domain.removePrefix("."))
.expiresAt(it.expires?.times(1000)?.toLong() ?: Long.MAX_VALUE)
.name(it.name)
.path(it.path)
.value(it.value).apply {
if (it.httpOnly) httpOnly()
if (it.secure) secure()
}.build()
}
} else {
logger.debug { "Cloudflare challenge failed to resolve" }
throw CloudflareBypassException()
}
}
// ref: https://github.com/vvanglro/cf-clearance/blob/44124a8f06d8d0ecf2bf558a027082ff88dab435/cf_clearance/stealth.py#L18
private val stealthInitScripts by lazy {
arrayOf(
ServerConfig::class.java.getResource("/cloudflare-js/canvas.fingerprinting.js")!!.readText(),
ServerConfig::class.java.getResource("/cloudflare-js/chrome.global.js")!!.readText(),
ServerConfig::class.java.getResource("/cloudflare-js/emulate.touch.js")!!.readText(),
ServerConfig::class.java.getResource("/cloudflare-js/navigator.permissions.js")!!.readText(),
ServerConfig::class.java.getResource("/cloudflare-js/navigator.webdriver.js")!!.readText(),
ServerConfig::class.java.getResource("/cloudflare-js/chrome.runtime.js")!!.readText(),
ServerConfig::class.java.getResource("/cloudflare-js/chrome.plugin.js")!!.readText()
)
}
// ref: https://github.com/vvanglro/cf-clearance/blob/44124a8f06d8d0ecf2bf558a027082ff88dab435/cf_clearance/stealth.py#L76
private fun applyStealthInitScripts(page: Page) {
for (script in stealthInitScripts) {
page.addInitScript(script)
}
}
// ref: https://github.com/vvanglro/cf-clearance/blob/44124a8f06d8d0ecf2bf558a027082ff88dab435/cf_clearance/retry.py#L21
private fun waitForChallengeResolve(page: Page): Boolean {
// sometimes the user has to solve the captcha challenge manually, potentially wait a long time
val timeoutSeconds = 120
repeat(timeoutSeconds) {
page.waitForTimeout(1.seconds.toDouble(DurationUnit.MILLISECONDS))
val success = try {
page.querySelector("#challenge-form") == null
} catch (e: Exception) {
logger.debug(e) { "query Error" }
false
}
if (success) return true
}
return false
}
private class CloudflareBypassException : Exception()
}

View File

@@ -0,0 +1,28 @@
(function () {
const ORIGINAL_CANVAS = HTMLCanvasElement.prototype[name];
Object.defineProperty(HTMLCanvasElement.prototype, name, {
"value": function () {
var shift = {
'r': Math.floor(Math.random() * 10) - 5,
'g': Math.floor(Math.random() * 10) - 5,
'b': Math.floor(Math.random() * 10) - 5,
'a': Math.floor(Math.random() * 10) - 5
};
var width = this.width,
height = this.height,
context = this.getContext("2d");
var imageData = context.getImageData(0, 0, width, height);
for (var i = 0; i < height; i++) {
for (var j = 0; j < width; j++) {
var n = ((i * (width * 4)) + (j * 4));
imageData.data[n + 0] = imageData.data[n + 0] + shift.r;
imageData.data[n + 1] = imageData.data[n + 1] + shift.g;
imageData.data[n + 2] = imageData.data[n + 2] + shift.b;
imageData.data[n + 3] = imageData.data[n + 3] + shift.a;
}
}
context.putImageData(imageData, 0, 0);
return ORIGINAL_CANVAS.apply(this, arguments);
}
});
})(this);

View File

@@ -0,0 +1,52 @@
Object.defineProperty(window, 'chrome', {
value: new Proxy(window.chrome, {
has: (target, key) => true,
get: (target, key) => {
return {
app: {
isInstalled: false,
},
webstore: {
onInstallStageChanged: {},
onDownloadProgress: {},
},
runtime: {
PlatformOs: {
MAC: 'mac',
WIN: 'win',
ANDROID: 'android',
CROS: 'cros',
LINUX: 'linux',
OPENBSD: 'openbsd',
},
PlatformArch: {
ARM: 'arm',
X86_32: 'x86-32',
X86_64: 'x86-64',
},
PlatformNaclArch: {
ARM: 'arm',
X86_32: 'x86-32',
X86_64: 'x86-64',
},
RequestUpdateCheckStatus: {
THROTTLED: 'throttled',
NO_UPDATE: 'no_update',
UPDATE_AVAILABLE: 'update_available',
},
OnInstalledReason: {
INSTALL: 'install',
UPDATE: 'update',
CHROME_UPDATE: 'chrome_update',
SHARED_MODULE_UPDATE: 'shared_module_update',
},
OnRestartRequiredReason: {
APP_UPDATE: 'app_update',
OS_UPDATE: 'os_update',
PERIODIC: 'periodic',
},
},
}
}
})
});

View File

@@ -0,0 +1,203 @@
(function () {
const plugin0 = Object.create(Plugin.prototype);
const mimeType0 = Object.create(MimeType.prototype);
const mimeType1 = Object.create(MimeType.prototype);
Object.defineProperties(mimeType0, {
type: {
get: () => 'application/pdf',
},
suffixes: {
get: () => 'pdf',
},
});
Object.defineProperties(mimeType1, {
type: {
get: () => 'text/pdf',
},
suffixes: {
get: () => 'pdf',
},
});
Object.defineProperties(plugin0, {
name: {
get: () => 'Chrome PDF Viewer',
},
description: {
get: () => 'Portable Document Format',
},
0: {
get: () => {
return mimeType0;
},
},
1: {
get: () => {
return mimeType1;
},
},
length: {
get: () => 2,
},
filename: {
get: () => 'internal-pdf-viewer',
},
});
const plugin1 = Object.create(Plugin.prototype);
Object.defineProperties(plugin1, {
name: {
get: () => 'Chromium PDF Viewer',
},
description: {
get: () => 'Portable Document Format',
},
0: {
get: () => {
return mimeType0;
},
},
1: {
get: () => {
return mimeType1;
},
},
length: {
get: () => 2,
},
filename: {
get: () => 'internal-pdf-viewer',
},
});
const plugin2 = Object.create(Plugin.prototype);
Object.defineProperties(plugin2, {
name: {
get: () => 'Microsoft Edge PDF Viewer',
},
description: {
get: () => 'Portable Document Format',
},
0: {
get: () => {
return mimeType0;
},
},
1: {
get: () => {
return mimeType1;
},
},
length: {
get: () => 2,
},
filename: {
get: () => 'internal-pdf-viewer',
},
});
const plugin3 = Object.create(Plugin.prototype);
Object.defineProperties(plugin3, {
name: {
get: () => 'PDF Viewer',
},
description: {
get: () => 'Portable Document Format',
},
0: {
get: () => {
return mimeType0;
},
},
1: {
get: () => {
return mimeType1;
},
},
length: {
get: () => 2,
},
filename: {
get: () => 'internal-pdf-viewer',
},
});
const plugin4 = Object.create(Plugin.prototype);
Object.defineProperties(plugin4, {
name: {
get: () => 'WebKit built-in PDF',
},
description: {
get: () => 'Portable Document Format',
},
0: {
get: () => {
return mimeType0;
},
},
1: {
get: () => {
return mimeType1;
},
},
length: {
get: () => 2,
},
filename: {
get: () => 'internal-pdf-viewer',
},
});
const pluginArray = Object.create(PluginArray.prototype);
pluginArray['0'] = plugin0;
pluginArray['1'] = plugin1;
pluginArray['2'] = plugin2;
pluginArray['3'] = plugin3;
pluginArray['4'] = plugin4;
let refreshValue;
Object.defineProperties(pluginArray, {
length: {
get: () => 5,
},
item: {
value: (index) => {
if (index > 4294967295) {
index = index % 4294967296;
}
switch (index) {
case 0:
return plugin3;
case 1:
return plugin0;
case 2:
return plugin1;
case 3:
return plugin2;
case 4:
return plugin4;
default:
break;
}
},
},
refresh: {
get: () => {
return refreshValue;
},
set: (value) => {
refreshValue = value;
},
},
});
Object.defineProperty(Object.getPrototypeOf(navigator), 'plugins', {
get: () => {
return pluginArray;
},
});
})();

View File

@@ -0,0 +1,170 @@
(function () {
window.chrome = {};
window.chrome.app = {
InstallState: {
DISABLED: 'disabled',
INSTALLED: 'installed',
NOT_INSTALLED: 'not_installed',
},
RunningState: {
CANNOT_RUN: 'cannot_run',
READY_TO_RUN: 'ready_to_run',
RUNNING: 'running',
},
getDetails: () => {
'[native code]';
},
getIsInstalled: () => {
'[native code]';
},
installState: () => {
'[native code]';
},
get isInstalled() {
return false;
},
runningState: () => {
'[native code]';
},
};
window.chrome.runtime = {
OnInstalledReason: {
CHROME_UPDATE: 'chrome_update',
INSTALL: 'install',
SHARED_MODULE_UPDATE: 'shared_module_update',
UPDATE: 'update',
},
OnRestartRequiredReason: {
APP_UPDATE: 'app_update',
OS_UPDATE: 'os_update',
PERIODIC: 'periodic',
},
PlatformArch: {
ARM: 'arm',
ARM64: 'arm64',
MIPS: 'mips',
MIPS64: 'mips64',
X86_32: 'x86-32',
X86_64: 'x86-64',
},
PlatformNaclArch: {
ARM: 'arm',
MIPS: 'mips',
MIPS64: 'mips64',
X86_32: 'x86-32',
X86_64: 'x86-64',
},
PlatformOs: {
ANDROID: 'android',
CROS: 'cros',
FUCHSIA: 'fuchsia',
LINUX: 'linux',
MAC: 'mac',
OPENBSD: 'openbsd',
WIN: 'win',
},
RequestUpdateCheckStatus: {
NO_UPDATE: 'no_update',
THROTTLED: 'throttled',
UPDATE_AVAILABLE: 'update_available',
},
connect() {
'[native code]';
},
sendMessage() {
'[native code]';
},
id: undefined,
};
let startE = Date.now();
window.chrome.csi = function () {
'[native code]';
return {
startE: startE,
onloadT: startE + 281,
pageT: 3947.235,
tran: 15,
};
};
window.chrome.loadTimes = function () {
'[native code]';
return {
get requestTime() {
return startE / 1000;
},
get startLoadTime() {
return startE / 1000;
},
get commitLoadTime() {
return startE / 1000 + 0.324;
},
get finishDocumentLoadTime() {
return startE / 1000 + 0.498;
},
get finishLoadTime() {
return startE / 1000 + 0.534;
},
get firstPaintTime() {
return startE / 1000 + 0.437;
},
get firstPaintAfterLoadTime() {
return 0;
},
get navigationType() {
return 'Other';
},
get wasFetchedViaSpdy() {
return true;
},
get wasNpnNegotiated() {
return true;
},
get npnNegotiatedProtocol() {
return 'h3';
},
get wasAlternateProtocolAvailable() {
return false;
},
get connectionInfo() {
return 'h3';
},
};
};
})();
// Bypass OOPIF test
(function performance_memory() {
const jsHeapSizeLimitInt = 4294705152;
const total_js_heap_size = 35244183;
const used_js_heap_size = [
17632315, 17632315, 17632315, 17634847, 17636091, 17636751,
];
let counter = 0;
let MemoryInfoProto = Object.getPrototypeOf(performance.memory);
Object.defineProperties(MemoryInfoProto, {
jsHeapSizeLimit: {
get: () => {
return jsHeapSizeLimitInt;
},
},
totalJSHeapSize: {
get: () => {
return total_js_heap_size;
},
},
usedJSHeapSize: {
get: () => {
if (counter > 5) {
counter = 0;
}
return used_js_heap_size[counter++];
},
},
});
})();

View File

@@ -0,0 +1,3 @@
Object.defineProperty(navigator, 'maxTouchPoints', {
get: () => 1
});

View File

@@ -0,0 +1,33 @@
// https://github.com/microlinkhq/browserless/blob/master/packages/goto/src/evasions/navigator-permissions.js
if (!window.Notification) {
window.Notification = {
permission: 'denied'
}
}
const originalQuery = window.navigator.permissions.query
window.navigator.permissions.__proto__.query = parameters =>
parameters.name === 'notifications'
? Promise.resolve({state: window.Notification.permission})
: originalQuery(parameters)
const oldCall = Function.prototype.call
function call() {
return oldCall.apply(this, arguments)
}
Function.prototype.call = call
const nativeToStringFunctionString = Error.toString().replace(/Error/g, 'toString')
const oldToString = Function.prototype.toString
function functionToString() {
if (this === window.navigator.permissions.query) {
return 'function query() { [native code] }'
}
if (this === functionToString) {
return nativeToStringFunctionString
}
return oldCall.call(oldToString, this)
}
// eslint-disable-next-line
Function.prototype.toString = functionToString

View File

@@ -0,0 +1,5 @@
Object.defineProperty(Navigator.prototype, 'webdriver', {
get() {
return false;
},
});

View File

@@ -0,0 +1,58 @@
package masstest
import eu.kanade.tachiyomi.source.online.HttpSource
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import mu.KotlinLogging
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import suwayomi.tachidesk.manga.impl.Source
import suwayomi.tachidesk.manga.impl.extension.Extension
import suwayomi.tachidesk.manga.impl.extension.ExtensionsList
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource
import suwayomi.tachidesk.server.applicationSetup
import suwayomi.tachidesk.test.BASE_PATH
import suwayomi.tachidesk.test.setLoggingEnabled
import xyz.nulldev.ts.config.CONFIG_PREFIX
import java.io.File
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class CloudFlareTest {
lateinit var nhentai: HttpSource
@BeforeAll
fun setup() {
val dataRoot = File(BASE_PATH).absolutePath
System.setProperty("$CONFIG_PREFIX.server.rootDir", dataRoot)
applicationSetup()
setLoggingEnabled(false)
runBlocking {
val extensions = ExtensionsList.getExtensionList()
with(extensions.first { it.name == "NHentai" }) {
if (!installed) {
Extension.installExtension(pkgName)
} else if (hasUpdate) {
Extension.updateExtension(pkgName)
}
Unit
}
nhentai = Source.getSourceList()
.firstNotNullOf { it.id.toLong().takeIf { it == 3122156392225024195L } }
.let(GetCatalogueSource::getCatalogueSourceOrNull) as HttpSource
}
setLoggingEnabled(true)
}
private val logger = KotlinLogging.logger {}
@Test
fun `test nhentai browse`() = runTest {
assert(nhentai.fetchPopularManga(1).awaitSingle().mangas.isNotEmpty()) {
"NHentai results were empty"
}
}
}