Improve WebView spoofing (#3678)

- Spoof `Sec-CH-UA` client hints to match the user agent
- Fix `X-Requested-With` spoofing leaking to unrelated callers
- Update default user agent to Chrome 149
This commit is contained in:
AntsyLich
2026-08-02 20:05:21 +06:00
committed by GitHub
parent d7f3ceef5c
commit 55be95dd5d
11 changed files with 71 additions and 7 deletions
+7
View File
@@ -14,8 +14,15 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
### Added ### Added
- Add Category filtering for the Updates tab ([@MajorTanya](https://github.com/MajorTanya)) ([#3589](https://github.com/mihonapp/mihon/pull/3589)) - Add Category filtering for the Updates tab ([@MajorTanya](https://github.com/MajorTanya)) ([#3589](https://github.com/mihonapp/mihon/pull/3589))
### Changed
- Update default user agent to Chrome 149 ([@AntsyLich](https://github.com/AntsyLich)) ([#3678](https://github.com/mihonapp/mihon/pull/3678))
### Improved
- Spoof `Sec-CH-UA` client hints in WebView to match the user agent ([@AntsyLich](https://github.com/AntsyLich)) ([#3678](https://github.com/mihonapp/mihon/pull/3678))
### Fixed ### Fixed
- Fixed extension installation with shizuku installer ([@NGB-Was-Taken](https://github.com/NGB-Was-Taken)) ([#3676](https://github.com/mihonapp/mihon/pull/3676)) - Fixed extension installation with shizuku installer ([@NGB-Was-Taken](https://github.com/NGB-Was-Taken)) ([#3676](https://github.com/mihonapp/mihon/pull/3676))
- Fixed `X-Requested-With` spoofing leaking to unrelated callers ([@AntsyLich](https://github.com/AntsyLich)) ([#3678](https://github.com/mihonapp/mihon/pull/3678))
## [v0.20.2] - 2026-08-01 ## [v0.20.2] - 2026-08-01
### Added ### Added
@@ -51,6 +51,7 @@ import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.system.getHtml import eu.kanade.tachiyomi.util.system.getHtml
import eu.kanade.tachiyomi.util.system.setDefaultSettings import eu.kanade.tachiyomi.util.system.setDefaultSettings
import eu.kanade.tachiyomi.util.system.setUserAgent
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.components.material.Scaffold
@@ -72,6 +73,7 @@ fun WebViewScreenContent(
onNavigateUp: () -> Unit, onNavigateUp: () -> Unit,
initialTitle: String?, initialTitle: String?,
url: String, url: String,
defaultUserAgentProvider: () -> String,
onShare: (String) -> Unit, onShare: (String) -> Unit,
onOpenInBrowser: (String) -> Unit, onOpenInBrowser: (String) -> Unit,
onClearCookies: (String) -> Unit, onClearCookies: (String) -> Unit,
@@ -340,9 +342,7 @@ fun WebViewScreenContent(
WebView.setWebContentsDebuggingEnabled(true) WebView.setWebContentsDebuggingEnabled(true)
} }
headers["user-agent"]?.let { webView.setUserAgent(headers["user-agent"] ?: defaultUserAgentProvider())
webView.settings.userAgentString = it
}
}, },
onDispose = { webView -> onDispose = { webView ->
val window = windowStack.items.find { it.webView == webView } val window = windowStack.items.find { it.webView == webView }
+1 -2
View File
@@ -8,7 +8,6 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.os.Build import android.os.Build
import android.os.Looper
import android.webkit.WebView import android.webkit.WebView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.DefaultLifecycleObserver
@@ -231,7 +230,7 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
override fun getPackageName(): String { override fun getPackageName(): String {
try { try {
// Override the value passed as X-Requested-With in WebView requests // Override the value passed as X-Requested-With in WebView requests
val stackTrace = Looper.getMainLooper().thread.stackTrace val stackTrace = Thread.currentThread().stackTrace
val isChromiumCall = stackTrace.any { trace -> val isChromiumCall = stackTrace.any { trace ->
trace.className.lowercase() in setOf("org.chromium.base.buildinfo", "org.chromium.base.apkinfo") && trace.className.lowercase() in setOf("org.chromium.base.buildinfo", "org.chromium.base.apkinfo") &&
trace.methodName.lowercase() in setOf("getall", "getpackagename", "<init>") trace.methodName.lowercase() in setOf("getall", "getpackagename", "<init>")
@@ -72,6 +72,7 @@ class WebViewActivity : BaseActivity() {
initialTitle = intent.extras?.getString(TITLE_KEY), initialTitle = intent.extras?.getString(TITLE_KEY),
url = url, url = url,
headers = headers, headers = headers,
defaultUserAgentProvider = network::defaultUserAgentProvider,
onUrlChange = { assistUrl = it }, onUrlChange = { assistUrl = it },
onShare = this::shareWebpage, onShare = this::shareWebpage,
onOpenInBrowser = this::openInBrowser, onOpenInBrowser = this::openInBrowser,
@@ -36,6 +36,7 @@ class WebViewScreen(
initialTitle = initialTitle, initialTitle = initialTitle,
url = url, url = url,
headers = viewModel.headers, headers = viewModel.headers,
defaultUserAgentProvider = viewModel::defaultUserAgentProvider,
onUrlChange = { assistUrl = it }, onUrlChange = { assistUrl = it },
onShare = { viewModel.shareWebpage(context, it) }, onShare = { viewModel.shareWebpage(context, it) },
onOpenInBrowser = { viewModel.openInBrowser(context, it) }, onOpenInBrowser = { viewModel.openInBrowser(context, it) },
@@ -67,4 +67,6 @@ class WebViewViewModel(
logcat { "Cleared $cleared cookies for: $url" } logcat { "Cleared $cleared cookies for: $url" }
} }
} }
fun defaultUserAgentProvider() = network.defaultUserAgentProvider()
} }
+1
View File
@@ -41,6 +41,7 @@ dependencies {
api(libs.kotlinx.serialization.jsonOkio) api(libs.kotlinx.serialization.jsonOkio)
api(libs.androidx.preference) api(libs.androidx.preference)
implementation(libs.androidx.webkit)
implementation(libs.jsoup) implementation(libs.jsoup)
@@ -14,6 +14,6 @@ class NetworkPreferences(
val defaultUserAgent: Preference<String> = preferenceStore.getString( val defaultUserAgent: Preference<String> = preferenceStore.getString(
"default_user_agent", "default_user_agent",
"Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Mobile Safari/537.36", "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Mobile Safari/537.36",
) )
} }
@@ -8,6 +8,7 @@ import android.widget.Toast
import eu.kanade.tachiyomi.util.system.DeviceUtil import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.WebViewUtil import eu.kanade.tachiyomi.util.system.WebViewUtil
import eu.kanade.tachiyomi.util.system.setDefaultSettings import eu.kanade.tachiyomi.util.system.setDefaultSettings
import eu.kanade.tachiyomi.util.system.setUserAgent
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import okhttp3.Headers import okhttp3.Headers
import okhttp3.Interceptor import okhttp3.Interceptor
@@ -84,7 +85,7 @@ abstract class WebViewInterceptor(
return WebView(context).apply { return WebView(context).apply {
setDefaultSettings() setDefaultSettings()
// Avoid sending empty User-Agent, Chromium WebView will reset to default if empty // Avoid sending empty User-Agent, Chromium WebView will reset to default if empty
settings.userAgentString = request.header("User-Agent") ?: defaultUserAgentProvider() setUserAgent(request.header("User-Agent") ?: defaultUserAgentProvider())
} }
} }
} }
@@ -6,6 +6,9 @@ import android.content.pm.PackageManager
import android.webkit.CookieManager import android.webkit.CookieManager
import android.webkit.WebSettings import android.webkit.WebSettings
import android.webkit.WebView import android.webkit.WebView
import androidx.webkit.UserAgentMetadata
import androidx.webkit.WebSettingsCompat
import androidx.webkit.WebViewFeature
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
@@ -99,6 +102,53 @@ fun WebView.setDefaultSettings() {
CookieManager.getInstance().acceptThirdPartyCookies(this) CookieManager.getInstance().acceptThirdPartyCookies(this)
} }
/**
* Sets the user agent along with the matching user agent metadata, which Chromium uses to populate
* the `Sec-CH-UA` client hints. Without this the hints keep advertising the real WebView brand and
* version, contradicting the spoofed user agent.
*/
fun WebView.setUserAgent(userAgent: String) {
settings.userAgentString = userAgent
if (!WebViewFeature.isFeatureSupported(WebViewFeature.USER_AGENT_METADATA)) return
val versionMatch = CHROME_VERSION_REGEX.find(userAgent) ?: return
val majorVersion = versionMatch.groupValues[1]
val fullVersion = majorVersion + versionMatch.groupValues[2].ifEmpty { ".0.0.0" }
try {
val metadata = WebSettingsCompat.getUserAgentMetadata(settings)
val brandVersionList = metadata.brandVersionList.map { brandVersion ->
val brand = when (brandVersion.brand) {
WEBVIEW_BRAND -> CHROME_BRAND
CHROMIUM_BRAND -> CHROMIUM_BRAND
else -> return@map brandVersion
}
UserAgentMetadata.BrandVersion.Builder()
.setBrand(brand)
.setMajorVersion(majorVersion)
.setFullVersion(fullVersion)
.build()
}
WebSettingsCompat.setUserAgentMetadata(
settings,
UserAgentMetadata.Builder(metadata)
.setBrandVersionList(brandVersionList)
.setFullVersion(fullVersion)
.build(),
)
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to set user agent metadata" }
}
}
private const val WEBVIEW_BRAND = "Android WebView"
private const val CHROMIUM_BRAND = "Chromium"
private const val CHROME_BRAND = "Google Chrome"
private val CHROME_VERSION_REGEX = """Chrome/(\d+)(\.[\d.]+)?""".toRegex()
private fun WebView.getWebViewMajorVersion(): Int { private fun WebView.getWebViewMajorVersion(): Int {
val uaRegexMatch = """.*Chrome/(\d+)\..*""".toRegex().matchEntire(getDefaultUserAgentString()) val uaRegexMatch = """.*Chrome/(\d+)\..*""".toRegex().matchEntire(getDefaultUserAgentString())
return if (uaRegexMatch != null && uaRegexMatch.groupValues.size > 1) { return if (uaRegexMatch != null && uaRegexMatch.groupValues.size > 1) {
+2
View File
@@ -24,6 +24,7 @@ androidx-test-espresso = "3.7.0"
androidx-test-junit = "1.3.0" androidx-test-junit = "1.3.0"
androidx-test-uiautomator = "2.4.0" androidx-test-uiautomator = "2.4.0"
androidx-viewPager = "1.1.0" androidx-viewPager = "1.1.0"
androidx-webkit = "1.16.0"
androidx-work = "2.11.2" androidx-work = "2.11.2"
archive = "1.1.6" archive = "1.1.6"
coil = "3.5.0" coil = "3.5.0"
@@ -113,6 +114,7 @@ androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core",
androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" } androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" }
androidx-test-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androidx-test-uiautomator" } androidx-test-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androidx-test-uiautomator" }
androidx-viewPager = { module = "androidx.viewpager:viewpager", version.ref = "androidx-viewPager" } androidx-viewPager = { module = "androidx.viewpager:viewpager", version.ref = "androidx-viewPager" }
androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "androidx-webkit" }
androidx-work = { module = "androidx.work:work-runtime", version.ref = "androidx-work" } androidx-work = { module = "androidx.work:work-runtime", version.ref = "androidx-work" }
archive = { module = "me.zhanghai.android.libarchive:library", version.ref = "archive" } archive = { module = "me.zhanghai.android.libarchive:library", version.ref = "archive" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }