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:
@@ -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 }
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -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) {
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user