From d3a9cfaeeff407c75db229c226f68c1402e05cd8 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 27 Oct 2026 15:37:05 +0300 Subject: [PATCH 1/4] Add opt-in strict VPN source validation and visibility status --- AGENTS.md | 6 + README.md | 1 + .../java/net/megaproxy487/VpnDeviceTest.kt | 11 +- .../java/net/megaproxy487/SourceProtection.kt | 11 + .../net/megaproxy487/SplitTunnelScreen.kt | 18 ++ .../java/net/megaproxy487/VisibilityScreen.kt | 22 +- .../java/net/megaproxy487/data/ConfigEdits.kt | 1 + .../java/net/megaproxy487/data/ConfigStore.kt | 4 +- .../net/megaproxy487/data/ConfigTransfer.kt | 2 +- .../net/megaproxy487/model/ProxyConfig.kt | 3 + .../net/megaproxy487/vpn/BridgeCallbacks.kt | 17 ++ .../java/net/megaproxy487/vpn/ProxyCore.kt | 20 +- .../net/megaproxy487/vpn/ProxyVpnService.kt | 8 +- app/src/main/res/values-ru/strings.xml | 11 + app/src/main/res/values/strings.xml | 11 + .../java/net/megaproxy487/ConfigEditsTest.kt | 2 +- .../net/megaproxy487/SourceProtectionTest.kt | 55 +++++ .../StrictSourceSettingsUiTest.kt | 29 +++ docs/en/dns.md | 24 ++ docs/en/release-testing.md | 6 + docs/ru/dns.md | 24 ++ docs/ru/release-testing.md | 7 + native/mobile/mobile.go | 6 +- native/mobile/source_icmp.go | 103 +++++++++ native/mobile/source_validation.go | 97 +++++++++ native/mobile/source_validation_test.go | 206 ++++++++++++++++++ scripts/android_device_tests.py | 4 + scripts/tests/test_android_device_tests.py | 2 + 28 files changed, 702 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/net/megaproxy487/SourceProtection.kt create mode 100644 app/src/test/java/net/megaproxy487/SourceProtectionTest.kt create mode 100644 app/src/test/java/net/megaproxy487/StrictSourceSettingsUiTest.kt create mode 100644 native/mobile/source_icmp.go create mode 100644 native/mobile/source_validation.go create mode 100644 native/mobile/source_validation_test.go diff --git a/AGENTS.md b/AGENTS.md index 3656474c..f480e7d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -167,6 +167,12 @@ branch names, credentials, signing material, or other secrets. downloads. Keep local profiles/selections and current tunnels; effective live changes mark pending reconnect rather than starting/stopping VPN automatically. +- Strict source validation is device-local, opt-in and Android 10+; apply on reconnection. + Disabled sessions retain the original native data path. Enabled sessions fail closed on + unknown/stale owners, validate TCP admission and every outgoing UDP datagram without positive + UID caching, and block TUN Echo while preserving ordinary ICMP error handling. Visibility + reports the active session, not proof of resistance to a separate-app bypass test. + ## Architecture landmarks - Validate networking optimizations against latency measurements. Prefer bounded racing of alternatives diff --git a/README.md b/README.md index 200ba4c0..53d7f029 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ All guides: [English](docs/en/index.md) / [Русский](docs/ru/index.md). - HTTPS CONNECT over HTTP/1.1 or multiplexed HTTP/2; optional HTTP/3 preference with HTTPS fallback. - SOCKS5 TCP/UDP, experimental MASQUE TCP/UDP over HTTP/3 and SSH. - Per-app routing, local-network bypass, optional IPv6 and Android Always-on VPN. +- Optional [strict source validation](docs/en/dns.md#strict-source-validation) on Android 10+: app ownership checks and ping blocking, applied on reconnection. - Multiple profiles, import/export, HTTPS configuration subscriptions and automatic failover. - DNS-over-HTTPS, optional plain TCP/UDP DNS, selectable TLS/QUIC/SSH fingerprints and custom JA3. - Traffic totals/rates, passive TCP/QUIC metrics, connection tests, download/upload speed tests diff --git a/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt b/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt index e2c0b8c3..e4577606 100644 --- a/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt +++ b/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt @@ -27,7 +27,12 @@ class VpnDeviceTest : DeviceTestBase() { @Test fun socks5AuthenticatedTcpUdpStopAndRestart() = socks5Traffic(true) @Test fun socks5AnonymousTcpUdpStopAndRestart() = socks5Traffic(false) - private fun socks5Traffic(authenticated: Boolean) { + @Test fun strictSourceValidatedTcpUdpStopAndRestart() { + org.junit.Assume.assumeTrue(Build.VERSION.SDK_INT >= 29) + socks5Traffic(true, strict = true) + } + + private fun socks5Traffic(authenticated: Boolean, strict: Boolean = false) { io { val profile = store.activeProfile() store.saveProfile(profile.copy(config = profile.config.copy( @@ -37,12 +42,13 @@ class VpnDeviceTest : DeviceTestBase() { password = if (authenticated) argument("proxyPassword") else "", ))) store.saveGlobalConnectionSettings(store.globalConnectionSettings().copy( - routeAllApps = false, selectedPackages = setOf(context.packageName), bypassLocalNetworks = false)) + routeAllApps = false, selectedPackages = setOf(context.packageName), bypassLocalNetworks = false, strictSourceValidation = strict)) } saved() directOriginUnavailable() repeat(2) { connect() + assertEquals(strict, ProxyVpnService.strictSourceValidationActive) roundTrip() listOf(1200, 1350, 4096).forEach(::udpRoundTrip) await("SOCKS5 protocol indicator did not update") { VpnRuntimeState.transportProtocol.value == VpnTransportProtocol.SOCKS5 } @@ -51,6 +57,7 @@ class VpnDeviceTest : DeviceTestBase() { } click(R.string.disconnect) stopped() + assertFalse(ProxyVpnService.strictSourceValidationActive) directOriginUnavailable() } } diff --git a/app/src/main/java/net/megaproxy487/SourceProtection.kt b/app/src/main/java/net/megaproxy487/SourceProtection.kt new file mode 100644 index 00000000..d561fe10 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/SourceProtection.kt @@ -0,0 +1,11 @@ +package net.megaproxy487 + +internal enum class SourceProtectionStatus { UNSUPPORTED, STOPPED, ACTIVE, DISABLED, RECONNECT } + +internal fun sourceProtectionStatus(sdk: Int, configured: Boolean, running: Boolean, active: Boolean): SourceProtectionStatus = when { + sdk < 29 -> SourceProtectionStatus.UNSUPPORTED + !running -> SourceProtectionStatus.STOPPED + configured != active -> SourceProtectionStatus.RECONNECT + active -> SourceProtectionStatus.ACTIVE + else -> SourceProtectionStatus.DISABLED +} diff --git a/app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt b/app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt index dea14d6a..107e550c 100644 --- a/app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt +++ b/app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt @@ -210,6 +210,24 @@ internal fun SplitTunnelScreen(activity: Activity, onBack: () -> Unit) { } } } + item { + val supported = android.os.Build.VERSION.SDK_INT >= 29 + Row( + Modifier.fillMaxWidth().heightIn(min = 56.dp).toggleable( + value = settings.strictSourceValidation, + enabled = supported || settings.strictSourceValidation, + onValueChange = { updateSettings(settings.copy(strictSourceValidation = it)) }, + role = Role.Switch, + ), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(stringResource(R.string.strict_source_title)) + Text(stringResource(if (supported) R.string.strict_source_description else R.string.strict_source_unsupported), style = MaterialTheme.typography.bodySmall) + } + Switch(settings.strictSourceValidation, null, enabled = supported || settings.strictSourceValidation) + } + } if (!settings.routeAllApps) { item { val checked = activity.packageName in settings.selectedPackages diff --git a/app/src/main/java/net/megaproxy487/VisibilityScreen.kt b/app/src/main/java/net/megaproxy487/VisibilityScreen.kt index 97cbb2ee..380eecfd 100644 --- a/app/src/main/java/net/megaproxy487/VisibilityScreen.kt +++ b/app/src/main/java/net/megaproxy487/VisibilityScreen.kt @@ -58,6 +58,8 @@ import java.net.URL import net.megaproxy487.data.boundedJsonObject private enum class VisibilityState(val labelResource: Int) { + ACTIVE(R.string.strict_source_active), + DISABLED(R.string.strict_source_disabled), DETECTED(R.string.detected), SELECTED_APPS_ONLY(R.string.selected_apps_only), NOT_DETECTED(R.string.not_detected), @@ -76,13 +78,14 @@ private data class VisibilityReport( val global: List, val selectedApps: List, val splitTunneling: Boolean, + val sourceProtection: VisibilityCheck, ) @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun VisibilityScreen(activity: Activity, onBack: () -> Unit, runNetworkChecks: Boolean = true) { var revision by remember { mutableStateOf(0) } - val report = readConfiguration(revision) { buildVisibilityReport(activity) } ?: return + val report = readConfiguration(revision to net.megaproxy487.vpn.VpnRuntimeState.connection.value) { buildVisibilityReport(activity) } ?: return var proxyCheckChecks by remember { mutableStateOf>(emptyList()) } LaunchedEffect(revision) { if (!runNetworkChecks) return@LaunchedEffect @@ -124,6 +127,7 @@ internal fun VisibilityScreen(activity: Activity, onBack: () -> Unit, runNetwork stringResource(if (report.splitTunneling) R.string.visible_to_selected_apps else R.string.vpn_network_signals), report.selectedApps, ) + VisibilitySection(stringResource(R.string.strict_source_section), listOf(report.sourceProtection)) VisibilitySection(stringResource(R.string.proxycheck_reputation), proxyCheckChecks) Text( stringResource(R.string.visibility_disclaimer), @@ -154,6 +158,8 @@ private fun VisibilitySection(title: String, checks: List) { @Composable private fun VisibilityBadge(state: VisibilityState) { val background = when (state) { + VisibilityState.ACTIVE -> Color(0xFF2E7D32) + VisibilityState.DISABLED -> MaterialTheme.colorScheme.outline VisibilityState.DETECTED -> Color(0xFFB3261E) VisibilityState.SELECTED_APPS_ONLY -> Color(0xFFF9A825) VisibilityState.NOT_DETECTED -> Color(0xFF2E7D32) @@ -312,6 +318,7 @@ private fun buildVisibilityReport(context: Context): VisibilityReport { vpnCheck(context.uiText(R.string.virtual_default_route), virtualDefaultRoute, context.uiText(R.string.virtual_route_yes), context.uiText(R.string.virtual_route_no)), vpnCheck(context.uiText(R.string.virtual_dns_address), virtualDns, context.uiText(R.string.virtual_dns_yes), context.uiText(R.string.virtual_dns_no)), ), + sourceProtection = sourceProtectionCheck(context, settings.strictSourceValidation), splitTunneling = splitTunneling, ) } @@ -319,3 +326,16 @@ private fun buildVisibilityReport(context: Context): VisibilityReport { private const val PROXYCHECK_LOOKUP_URL = "https://proxycheck.io/v3/" private const val PROXYCHECK_CACHE = "proxycheck_visibility_cache" private const val PROXYCHECK_CACHE_TTL_MS = 24 * 60 * 60 * 1000L + +private fun sourceProtectionCheck(context: Context, configured: Boolean): VisibilityCheck { + val active = net.megaproxy487.vpn.ProxyVpnService.strictSourceValidationActive + val status = sourceProtectionStatus(Build.VERSION.SDK_INT, configured, net.megaproxy487.vpn.ProxyVpnService.isRunning, active) + val (state, detail) = when (status) { + SourceProtectionStatus.UNSUPPORTED -> VisibilityState.UNAVAILABLE to R.string.strict_source_unsupported + SourceProtectionStatus.STOPPED -> VisibilityState.UNAVAILABLE to R.string.strict_source_stopped + SourceProtectionStatus.ACTIVE -> VisibilityState.ACTIVE to R.string.strict_source_active_detail + SourceProtectionStatus.DISABLED -> VisibilityState.DISABLED to R.string.strict_source_disabled_detail + SourceProtectionStatus.RECONNECT -> VisibilityState.UNKNOWN to if (active) R.string.strict_source_pending_disable else R.string.strict_source_pending_enable + } + return VisibilityCheck(context.uiText(R.string.strict_source_title), state, context.uiText(detail)) +} diff --git a/app/src/main/java/net/megaproxy487/data/ConfigEdits.kt b/app/src/main/java/net/megaproxy487/data/ConfigEdits.kt index 2baf733d..86bddd3a 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigEdits.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigEdits.kt @@ -89,6 +89,7 @@ internal fun ConfigEdits.profile(before: ProxyProfile, after: Prox internal fun ConfigEdits.settings(before: GlobalConnectionSettings, after: GlobalConnectionSettings): ConfigEdit { record("mtu", before.mtu, after.mtu) { it.copy(mtu = after.mtu) } + record("strictSourceValidation", before.strictSourceValidation, after.strictSourceValidation) { it.copy(strictSourceValidation = after.strictSourceValidation) } record("tlsProfile", before.tlsProfile, after.tlsProfile) { it.copy(tlsProfile = after.tlsProfile) } record("sshProfile", before.sshProfile, after.sshProfile) { it.copy(sshProfile = after.sshProfile) } record("sshAuthMode", before.sshAuthMode, after.sshAuthMode) { it.copy(sshAuthMode = after.sshAuthMode) } diff --git a/app/src/main/java/net/megaproxy487/data/ConfigStore.kt b/app/src/main/java/net/megaproxy487/data/ConfigStore.kt index a3647ee2..e70947a8 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigStore.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigStore.kt @@ -770,11 +770,12 @@ class ConfigStore(context: Context) { put("routeAllApps", settings.routeAllApps) put("bypassLocalNetworks", settings.bypassLocalNetworks) put("mtu", settings.mtu) + put("strictSourceValidation", settings.strictSourceValidation) }.toString() private fun decodeGlobalConnectionSettings(value: String): GlobalConnectionSettings = operationResult { val item = JSONObject(value) - for (key in listOf("routeAllApps", "bypassLocalNetworks", "allowIpv6", "megaProxyRoutingDefaultApplied")) { + for (key in listOf("routeAllApps", "bypassLocalNetworks", "allowIpv6", "megaProxyRoutingDefaultApplied", "strictSourceValidation")) { require(!item.has(key) || item.get(key) is Boolean) } for (key in listOf("packages", "failoverProfileIds")) { @@ -791,6 +792,7 @@ class ConfigStore(context: Context) { val parsedTls = enumValue(item.optString("fingerprint"), TlsProfile.DEFAULT) GlobalConnectionSettings( mtu = mtu, + strictSourceValidation = item.optBoolean("strictSourceValidation", false), tlsProfile = parsedTls.takeIf { it.available } ?: TlsProfile.DEFAULT, sshProfile = enumValue(item.optString("sshProfile"), SshProfile.DEFAULT), sshAuthMode = enumValue(item.optString("sshAuthMode"), SshAuthMode.AUTO), diff --git a/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt b/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt index 935fb584..bc23907b 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt @@ -39,7 +39,7 @@ data class PortableConfiguration( ) { internal fun mergeGlobalSettings(current: GlobalConnectionSettings): GlobalConnectionSettings { val incoming = globalConnectionSettings ?: return current - val fields = globalSettingFields ?: return incoming.copy(mtu = current.mtu) + val fields = globalSettingFields ?: return incoming.copy(mtu = current.mtu, strictSourceValidation = current.strictSourceValidation) return current.copy( tlsProfile = if ("tls.fingerprint" in fields) incoming.tlsProfile else current.tlsProfile, customJa3 = if ("tls.customJa3" in fields) incoming.customJa3 else current.customJa3, diff --git a/app/src/main/java/net/megaproxy487/model/ProxyConfig.kt b/app/src/main/java/net/megaproxy487/model/ProxyConfig.kt index 1d03c65d..a54c5aa2 100644 --- a/app/src/main/java/net/megaproxy487/model/ProxyConfig.kt +++ b/app/src/main/java/net/megaproxy487/model/ProxyConfig.kt @@ -27,6 +27,7 @@ data class ProxyConfig( val routeAllApps: Boolean = true, val bypassLocalNetworks: Boolean = true, val mtu: Int = DEFAULT_VPN_MTU, + val strictSourceValidation: Boolean = false, val resolvedProxyIp: String = "", val privateKey: String = "", val sshProfile: SshProfile = SshProfile.DEFAULT, @@ -227,6 +228,7 @@ data class GlobalConnectionSettings( val routeAllApps: Boolean = true, val bypassLocalNetworks: Boolean = true, val mtu: Int = DEFAULT_VPN_MTU, + val strictSourceValidation: Boolean = false, ) { fun applyTo(config: ProxyConfig): ProxyConfig = config.copy( profile = if (tlsProfile == TlsProfile.DEFAULT) TlsProfile.CHROME_ANDROID else tlsProfile, @@ -241,6 +243,7 @@ data class GlobalConnectionSettings( routeAllApps = routeAllApps, bypassLocalNetworks = bypassLocalNetworks, mtu = mtu, + strictSourceValidation = strictSourceValidation, ) } diff --git a/app/src/main/java/net/megaproxy487/vpn/BridgeCallbacks.kt b/app/src/main/java/net/megaproxy487/vpn/BridgeCallbacks.kt index fa9f07d0..2b1fed71 100644 --- a/app/src/main/java/net/megaproxy487/vpn/BridgeCallbacks.kt +++ b/app/src/main/java/net/megaproxy487/vpn/BridgeCallbacks.kt @@ -34,3 +34,20 @@ internal class BridgeReporter( } } } + + +internal class BridgeSourceValidator( + private val enabled: () -> Boolean, + private val allowedUids: Set?, + private val resolve: (Int, String, Int, String, Int) -> Int, +) : mobile.SourceValidator { + override fun allow(protocol: Long, source: String, sourcePort: Long, destination: String, destinationPort: Long): Boolean { + if (!enabled() || (protocol != 6L && protocol != 17L) || sourcePort !in 1..65535L || destinationPort !in 1..65535L) return false + return try { + val uid = resolve(protocol.toInt(), source, sourcePort.toInt(), destination, destinationPort.toInt()) + enabled() && uid >= 0 && (allowedUids == null || uid in allowedUids) + } catch (_: Exception) { + false + } + } +} diff --git a/app/src/main/java/net/megaproxy487/vpn/ProxyCore.kt b/app/src/main/java/net/megaproxy487/vpn/ProxyCore.kt index 66034e2a..b10da4f4 100644 --- a/app/src/main/java/net/megaproxy487/vpn/ProxyCore.kt +++ b/app/src/main/java/net/megaproxy487/vpn/ProxyCore.kt @@ -144,7 +144,25 @@ class NativeProxyCore( // Java keeps its duplicate alive for the call; Go duplicates it on entry. // Even a linkage failure before Go is entered cannot leak a detached FD. ParcelFileDescriptor.fromFd(tunFd).use { borrowed -> - Mobile.start(borrowed.fd.toLong(), mtu.toLong(), json, protector(), reporter) + if (config.strictSourceValidation) { + if (android.os.Build.VERSION.SDK_INT < 29) throw IllegalStateException(vpnService.getString(net.megaproxy487.R.string.strict_source_unsupported)) + val uids = if (config.routeAllApps) null else config.selectedPackages.mapNotNull { name -> + runCatching { vpnService.packageManager.getApplicationInfo(name, 0).uid }.getOrNull() + }.toSet().ifEmpty { setOf(vpnService.applicationInfo.uid) } + val connectivity = vpnService.getSystemService(android.net.ConnectivityManager::class.java) + val validator = BridgeSourceValidator( + { callbacksEnabled.get() && isCurrent() }, + uids, + { protocol, source, sourcePort, destination, destinationPort -> + connectivity.getConnectionOwnerUid(protocol, + java.net.InetSocketAddress(source, sourcePort), + java.net.InetSocketAddress(destination, destinationPort)) + }, + ) + Mobile.startStrict(borrowed.fd.toLong(), mtu.toLong(), json, protector(), reporter, validator) + } else { + Mobile.start(borrowed.fd.toLong(), mtu.toLong(), json, protector(), reporter) + } nativeStarted = true } status("TCP is protected by ${config.type.title}") diff --git a/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt b/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt index 22b48bf3..f66a2f87 100644 --- a/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt +++ b/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt @@ -489,7 +489,9 @@ class ProxyVpnService : VpnService() { if (!isStartCurrent(generation)) { return false } - val config = storedConfig.copy(resolvedProxyIp = proxyIp, resolvedJumpIp = jumpIp) + val config = storedConfig.copy(resolvedProxyIp = proxyIp, resolvedJumpIp = jumpIp).let { + if (testOnly && it.strictSourceValidation) it.copy(routeAllApps = false, selectedPackages = setOf(packageName)) else it + } val startupBlockingDetail = java.util.concurrent.atomic.AtomicReference(null) val started = proxyCore.start(establishedTunnel.fd, storedConfig.mtu, config) { message -> failureDetail = message @@ -519,6 +521,7 @@ class ProxyVpnService : VpnService() { core = proxyCore tunnelTestOnly = testOnly activeSession = ConnectionTestTarget(promptProfileId, config) + strictSourceValidationActive = config.strictSourceValidation isRunning = true true } @@ -861,6 +864,7 @@ class ProxyVpnService : VpnService() { tunnelTestOnly = false activeSession = null isRunning = false + strictSourceValidationActive = false result } SubscriptionNotifications.cancel(this) @@ -967,6 +971,8 @@ class ProxyVpnService : VpnService() { .build() companion object { + @Volatile var strictSourceValidationActive: Boolean = false + private set private val serviceThread = HandlerThread("megaproxy-vpn-control").apply { start() } private const val CHANNEL_ID = "vpn" private const val NOTIFICATION_ID = 101 diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 7e379858..5edaf482 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -589,4 +589,15 @@ Введите целое число от 1280 до 65535. Для всех профилей на этом устройстве, после переподключения. До 7 HTTPS URL, по одному на строку. + Строгая проверка источника + Проверять приложение перед передачей TCP и каждой UDP-датаграммы. Блокирует неизвестных владельцев и ping; может замедлить UDP или блокировать приложения. Применяется при переподключении. + Требуется Android 10 или новее. + Защита источника трафика + Работает + Выключена + VPN не запущен. Подключитесь, чтобы проверить работающую защиту. + Текущий туннель проверяет TCP и каждую исходящую UDP-датаграмму и блокирует ping. Это статус сеанса, а не тест обхода из другого приложения. Наличие VPN по-прежнему можно обнаружить. + Текущий туннель использует маршрутизацию приложений Android без дополнительной проверки источника. + Защита выбрана, но не работает в текущем туннеле. Переподключитесь для включения. + Защита ещё работает в текущем туннеле. Переподключитесь для выключения. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3115218e..9a12e136 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -590,4 +590,15 @@ Enter a whole number from 1280 to 65535. Applies to all profiles on this device after reconnection. Up to 7 HTTPS URLs, one per line. + Strict source validation + Verify app ownership before forwarding TCP and every UDP datagram. Blocks unknown owners and ping; may slow UDP or block apps. Applies on reconnection. + Requires Android 10 or later. + Traffic source protection + Active + Disabled + VPN is not running. Connect to check the active protection. + The current tunnel validates TCP and each outgoing UDP datagram and blocks ping. This is a session status, not a bypass test from another app. VPN presence can still be detected. + The current tunnel uses Android app routing without additional source validation. + Protection is selected but is not active in the current tunnel. Reconnect to enable it. + Protection is still active in the current tunnel. Reconnect to disable it. diff --git a/app/src/test/java/net/megaproxy487/ConfigEditsTest.kt b/app/src/test/java/net/megaproxy487/ConfigEditsTest.kt index f640bedc..e32b1e83 100644 --- a/app/src/test/java/net/megaproxy487/ConfigEditsTest.kt +++ b/app/src/test/java/net/megaproxy487/ConfigEditsTest.kt @@ -44,7 +44,7 @@ class ConfigEditsTest { @Test fun globalFieldEditsPreserveNewRoutingFailoverAndFingerprintValues() { val initial = GlobalConnectionSettings() val remote = initial.copy(tlsProfile = TlsProfile.FIREFOX_ANDROID, bypassLocalNetworks = false, - failoverMode = FailoverMode.ALL, customJa3 = "remote", selectedPackages = setOf("new.package"), mtu = 1420) + failoverMode = FailoverMode.ALL, customJa3 = "remote", selectedPackages = setOf("new.package"), mtu = 1420, strictSourceValidation = true) var saved = remote ConfigEdits().settings(initial, initial.copy(sshKeepaliveSeconds = 60)) .write { saved = it(saved) } diff --git a/app/src/test/java/net/megaproxy487/SourceProtectionTest.kt b/app/src/test/java/net/megaproxy487/SourceProtectionTest.kt new file mode 100644 index 00000000..38899003 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/SourceProtectionTest.kt @@ -0,0 +1,55 @@ +package net.megaproxy487 + +import net.megaproxy487.vpn.BridgeSourceValidator +import org.junit.Assert.* +import org.junit.Test + +class SourceProtectionTest { + @Test fun unknownOwnersExceptionsAndStaleSessionsFailClosed() { + var uid = 123 + var current = true + var calls = 0 + var throws = false + val validator = BridgeSourceValidator({ current }, setOf(123)) { protocol, source, sourcePort, destination, destinationPort -> + calls++ + assertEquals(17, protocol) + assertEquals("10.77.0.1", source) + assertEquals(1234, sourcePort) + assertEquals("192.0.2.1", destination) + assertEquals(53, destinationPort) + if (throws) throw SecurityException("synthetic") + uid + } + fun allowed() = validator.allow(17, "10.77.0.1", 1234, "192.0.2.1", 53) + assertTrue(allowed()) + uid = 456 + assertFalse(allowed()) + uid = -1 + assertFalse(allowed()) + throws = true + assertFalse(allowed()) + current = false + val before = calls + assertFalse(allowed()) + assertEquals(before, calls) + assertFalse(validator.allow(17, "10.77.0.1", 1L shl 32, "192.0.2.1", 53)) + } + + @Test fun profileImportsCannotDisableLocalProtection() { + val current = net.megaproxy487.model.GlobalConnectionSettings(strictSourceValidation = true) + for (fields in listOf?>(null, emptySet(), setOf("routing.routeAllApps"))) { + val imported = net.megaproxy487.data.PortableConfiguration(emptyList(), null, null, + globalConnectionSettings = net.megaproxy487.model.GlobalConnectionSettings(), globalSettingFields = fields) + assertTrue(imported.mergeGlobalSettings(current).strictSourceValidation) + } + } + + @Test fun visibilityDescribesRunningSessionRatherThanJustPreference() { + assertEquals(SourceProtectionStatus.UNSUPPORTED, sourceProtectionStatus(26, true, true, false)) + assertEquals(SourceProtectionStatus.STOPPED, sourceProtectionStatus(35, true, false, false)) + assertEquals(SourceProtectionStatus.DISABLED, sourceProtectionStatus(35, false, true, false)) + assertEquals(SourceProtectionStatus.RECONNECT, sourceProtectionStatus(35, true, true, false)) + assertEquals(SourceProtectionStatus.RECONNECT, sourceProtectionStatus(35, false, true, true)) + assertEquals(SourceProtectionStatus.ACTIVE, sourceProtectionStatus(35, true, true, true)) + } +} diff --git a/app/src/test/java/net/megaproxy487/StrictSourceSettingsUiTest.kt b/app/src/test/java/net/megaproxy487/StrictSourceSettingsUiTest.kt new file mode 100644 index 00000000..488f2a20 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/StrictSourceSettingsUiTest.kt @@ -0,0 +1,29 @@ +package net.megaproxy487 + +import androidx.compose.ui.test.* +import net.megaproxy487.data.ConfigStore +import org.junit.Assert.* +import org.junit.Test + +class StrictSourceSettingsUiTest : MainUiTestBase() { + @Test @org.robolectric.annotation.Config(sdk = [26]) + fun unsupportedDevicesCannotEnableProtection() { + content { SplitTunnelScreen(activity) {} } + compose.onNode(hasScrollAction()).performScrollToNode(hasText(activity.getString(R.string.strict_source_title))) + compose.onNode(isToggleable() and hasText(activity.getString(R.string.strict_source_title))).assertIsNotEnabled() + assertFalse(store.globalConnectionSettings().strictSourceValidation) + } + + @Test fun protectionIsOptInAndPersistsAsLocalSetting() { + assertFalse(store.globalConnectionSettings().strictSourceValidation) + content { SplitTunnelScreen(activity) {} } + compose.onNode(hasScrollAction()).performScrollToNode(hasText(activity.getString(R.string.strict_source_title))) + node(R.string.strict_source_title).performClick() + saved() + assertTrue(ConfigStore(activity).globalConnectionSettings().strictSourceValidation) + assertTrue(ConfigStore(activity).effectiveConnectionProfile().config.strictSourceValidation) + node(R.string.strict_source_title).performClick() + saved() + assertFalse(ConfigStore(activity).globalConnectionSettings().strictSourceValidation) + } +} diff --git a/docs/en/dns.md b/docs/en/dns.md index 3775b62a..295b53c0 100644 --- a/docs/en/dns.md +++ b/docs/en/dns.md @@ -45,3 +45,27 @@ DoH alternatives start 200 ms apart; the first valid response wins and outstandi cancelled. Slow requests may therefore reach multiple configured providers. CUSTOM and filtering presets have no fallback providers. Proxy bootstrap uses the same strategy with its existing provider list, independently of profile DNS. Plain DNS has only the selected resolver. + +## Strict source validation + +In **Split tunneling**, optional **Strict source validation** requires Android 10+ and +applies when the VPN reconnects. It is local to the device, defaults to off and is not +included in profile exports or subscriptions. With it off, the native stack retains its +original transport and link handlers: no UID queries, validation caches or packet checks. + +When enabled, TCP ownership is checked before proxy/direct-LAN forwarding; UDP ownership is +checked on admission and for every outgoing datagram, including DNS, without a positive +cache that could outlive the Android socket. Unknown owners, lookup exceptions and stale +sessions are denied. Split mode accepts only the selected application UIDs; global mode +requires an identifiable owner. Packages sharing a UID cannot be distinguished. The +ownership lookup and forwarding are separate operations, not an atomic kernel guarantee. +Slow new-flow lookups run outside the shared TUN loop with at most 64 concurrent admissions; +excess attempts are closed. UDP checks add latency and CPU work and may disrupt apps or +traffic without a visible owner, such as tethering. No direct retry bypasses a denial. + +IPv4/IPv6 Echo (ping) is blocked before the userspace stack; ordinary ICMP errors retain +normal processing. Fragmented ICMP and IPv6 fragments with an unidentifiable transport are +also dropped. The physical network's ICMP is unaffected. The app's TCP/HTTP diagnostics +continue working. **Visibility** reports the active tunnel's protection and whether a +reconnect is needed. This is a session status, not an attack test from another application, +and does not hide VPN interfaces or other system-visible VPN signals. diff --git a/docs/en/release-testing.md b/docs/en/release-testing.md index dc4af2cf..ce7ef2cd 100644 --- a/docs/en/release-testing.md +++ b/docs/en/release-testing.md @@ -132,3 +132,9 @@ and a multiline private key. They require a real keyboard to open, enough visibl field height above it, and navigation returning when the keyboard closes. Inspect typing, cursor visibility and scrolling as well. In short windows the app bar disappears while typing; notices move into the scrollable form. Orientation and font arguments affect only the test Activity. Afterward uninstall only `net.megaproxy487.keyboardreview.test` and `net.megaproxy487.keyboardreview`. + +For strict source validation changes, check opt-in persistence and pending reconnect status, +API 26 unavailability, and API 35 real TCP/UDP/DNS through JNI. Check unknown/stale UID denial, +UDP source-port reuse, both Echo families and preserved MTU errors with deterministic native/JVM +tests. Verify disabled sessions install no filtering wrappers. A visibility status is not a +separate-UID bypass test; use a controlled excluded helper app for manual attack reproduction. diff --git a/docs/ru/dns.md b/docs/ru/dns.md index f42b467a..4e3c343a 100644 --- a/docs/ru/dns.md +++ b/docs/ru/dns.md @@ -48,3 +48,27 @@ TCP/UDP-адреса и не должны трактовать их как DoH. провайдерам. У CUSTOM и фильтрующих провайдеров фолбеков нет. Начальное разрешение адреса прокси использует тот же подход со своим списком провайдеров независимо от DNS профиля. У обычного DNS остаётся только выбранный сервер. + +## Строгая проверка источника + +В **Раздельном туннелировании** доступна опция **Строгая проверка источника** для Android 10+. +Применяется при переподключении VPN, по умолчанию выключена, хранится локально и не входит +в экспорт профилей и подписки. При выключенной опции нативный стек использует прежние +обработчики: без запросов UID, кешей проверки и дополнительных проверок пакетов. + +При включении владелец TCP проверяется до выхода через прокси или напрямую в LAN, UDP — +при создании потока и на каждой исходящей датаграмме, включая DNS, без положительного кеша, +способного пережить Android-сокет. Неизвестный владелец, ошибка API и устаревший сеанс +блокируются. В split-режиме разрешены только выбранные UID; в глобальном требуется +определимый владелец. Приложения с общим UID неразличимы. Запрос владельца и передача — +раздельные операции, а не атомарная гарантия ядра. Медленные проверки новых потоков идут +вне общего цикла TUN с ограничением 64 одновременных проверок; лишние попытки закрываются. +Проверки UDP увеличивают задержку и нагрузку и могут нарушать работу приложений и трафика +без определимого владельца, например раздачи интернета. Прямого обхода блокировки нет. + +Echo (ping) IPv4/IPv6 блокируется до userspace-стека; обычные ошибки ICMP обрабатываются +штатно. Фрагментированный ICMP и IPv6-фрагменты с неопределимым транспортом также блокируются. +ICMP физического соединения не затрагивается. TCP/HTTP-диагностика приложения продолжает +работать. **Проверка видимости** показывает защиту текущего туннеля и необходимость +переподключения. Это статус сеанса, а не проверка атаки из другого приложения; +он не скрывает VPN-интерфейсы и другие системные признаки VPN. diff --git a/docs/ru/release-testing.md b/docs/ru/release-testing.md index e88b1fe9..8f253d9b 100644 --- a/docs/ru/release-testing.md +++ b/docs/ru/release-testing.md @@ -135,3 +135,10 @@ adb -s SERIAL shell am instrument -w -r -e class net.megaproxy487.KeyboardInsets скрывается на время ввода, а сообщения переходят в прокручиваемую форму. Аргументы ориентации и шрифта влияют только на тестовую Activity. После проверки удалите только `net.megaproxy487.keyboardreview.test` и `net.megaproxy487.keyboardreview`. + +При изменении строгой проверки источника проверьте сохранение opt-in и статус до +переподключения, недоступность на API 26 и реальный TCP/UDP/DNS через JNI на API 35. +Блокировку неизвестного/устаревшего UID, повторное использование UDP-порта, Echo обоих +семейств и сохранение ошибок MTU проверяют детерминированные native/JVM-тесты. +Выключенный режим не устанавливает фильтрующие обёртки. Статус видимости не заменяет тест +обхода другим UID; для ручного воспроизведения используйте контролируемое исключённое приложение. diff --git a/native/mobile/mobile.go b/native/mobile/mobile.go index d326e30f..f6b68bd9 100644 --- a/native/mobile/mobile.go +++ b/native/mobile/mobile.go @@ -36,6 +36,10 @@ var state struct { // Start borrows tunFD for this call. Android keeps its ParcelFileDescriptor open; // Go owns only the duplicate made here, including every failure path. func Start(tunFD int, mtu int, rawConfig string, protector Protector, reporter Reporter) error { + return start(tunFD, mtu, rawConfig, protector, reporter, nil) +} + +func start(tunFD int, mtu int, rawConfig string, protector Protector, reporter Reporter, validator SourceValidator) error { tunFD, err := duplicateTunFD(tunFD) if err != nil { return err @@ -124,7 +128,7 @@ func Start(tunFD int, mtu int, rawConfig string, protector Protector, reporter R t.SetProxy(sshProxy) proxyCloser = sshProxy } - netstack, err := core.CreateStack(&core.Config{LinkEndpoint: dev, TransportHandler: dnsTransport{t}}) + netstack, err := core.CreateStack(sourceStackConfig(dev, dnsTransport{t}, validator)) if err != nil { dev.Close() if proxyCloser != nil { diff --git a/native/mobile/source_icmp.go b/native/mobile/source_icmp.go new file mode 100644 index 00000000..b539156f --- /dev/null +++ b/native/mobile/source_icmp.go @@ -0,0 +1,103 @@ +package mobile + +import ( + "encoding/binary" + + "gvisor.dev/gvisor/pkg/tcpip" + "gvisor.dev/gvisor/pkg/tcpip/stack" +) + +// Only installed for strict sessions, before gVisor can synthesize Echo Replies. +type sourceLinkEndpoint struct{ stack.LinkEndpoint } + +func (e *sourceLinkEndpoint) Attach(dispatcher stack.NetworkDispatcher) { + if dispatcher == nil { + e.LinkEndpoint.Attach(nil) + return + } + e.LinkEndpoint.Attach(sourceDispatcher{dispatcher}) +} + +type sourceDispatcher struct{ stack.NetworkDispatcher } + +func (d sourceDispatcher) DeliverNetworkPacket(protocol tcpip.NetworkProtocolNumber, pkt *stack.PacketBuffer) { + // ponytail: strict-only packet copy, bounded by TUN MTU; use header views if profiling warrants. + view := pkt.ToView() + blocked := blockSourceEcho(view.AsSlice()) + view.Release() + if !blocked { + d.NetworkDispatcher.DeliverNetworkPacket(protocol, pkt) + } +} + +// Preserve ICMP errors (including Packet Too Big); block Echo, even behind IPv6 extensions. +func blockSourceEcho(p []byte) bool { + if len(p) == 0 { + return true + } + switch p[0] >> 4 { + case 4: + if len(p) < 20 { + return true + } + if p[9] != 1 { + return false + } + offset := int(p[0]&15) * 4 + if offset < 20 || len(p) <= offset { + return true + } + // Fragmented ICMP cannot be attributed safely without a separate reassembly cache. + if binary.BigEndian.Uint16(p[6:8])&0x3fff != 0 { + return true + } + return p[offset] == 0 || p[offset] == 8 + case 6: + if len(p) < 40 { + return true + } + next, offset := p[6], 40 + for steps := 0; steps < 16; steps++ { + switch next { + case 58: + return len(p) <= offset || p[offset] == 128 || p[offset] == 129 + case 0, 43, 60: // Hop-by-hop, routing, destination options. + if len(p) < offset+2 { + return true + } + length := (int(p[offset+1]) + 1) * 8 + if len(p) < offset+length { + return true + } + next, offset = p[offset], offset+length + case 44: + if len(p) < offset+8 { + return true + } + fragmentNext := p[offset] + if binary.BigEndian.Uint16(p[offset+2:offset+4])&0xfff8 != 0 { + // Non-initial TCP/UDP fragments still undergo validation after reassembly. + return fragmentNext != 6 && fragmentNext != 17 + } + if fragmentNext == 58 { + return true + } + next, offset = fragmentNext, offset+8 + case 51: // Authentication header. + if len(p) < offset+2 { + return true + } + length := (int(p[offset+1]) + 2) * 4 + if len(p) < offset+length { + return true + } + next, offset = p[offset], offset+length + default: + return false + } + } + return true + default: + return true + } +} diff --git a/native/mobile/source_validation.go b/native/mobile/source_validation.go new file mode 100644 index 00000000..890c0702 --- /dev/null +++ b/native/mobile/source_validation.go @@ -0,0 +1,97 @@ +package mobile + +import ( + "errors" + "net" + + "github.com/xjasonlyu/tun2socks/v2/core" + "github.com/xjasonlyu/tun2socks/v2/core/adapter" + "gvisor.dev/gvisor/pkg/tcpip/stack" +) + +// SourceValidator is implemented by the active Android VPN. Unknown owners fail closed. +// Addresses are numeric, and the source is always the Android application socket. +type SourceValidator interface { + Allow(protocol int, source string, sourcePort int, destination string, destinationPort int) bool +} + +// StartStrict uses a separate entry point so normal Start keeps its existing data path. +func StartStrict(tunFD int, mtu int, rawConfig string, protector Protector, reporter Reporter, validator SourceValidator) error { + if validator == nil { + return errors.New("strict source validator is required") + } + return start(tunFD, mtu, rawConfig, protector, reporter, validator) +} + +type sourceTransport struct { + adapter.TransportHandler + validator SourceValidator + slots chan struct{} +} + +func sourceAllowed(validator SourceValidator, protocol int, id stack.TransportEndpointID) bool { + return validator.Allow(protocol, net.IP(id.RemoteAddress.AsSlice()).String(), int(id.RemotePort), net.IP(id.LocalAddress.AsSlice()).String(), int(id.LocalPort)) +} + +// Bound slow Android lookups without stalling the shared TUN receive loop. +func (t sourceTransport) dispatch(conn interface{ Close() error }, run func()) { + select { + case t.slots <- struct{}{}: + go func() { defer func() { <-t.slots }(); run() }() + default: + _ = conn.Close() + } +} +func (t sourceTransport) HandleTCP(conn adapter.TCPConn) { + t.dispatch(conn, func() { + if !sourceAllowed(t.validator, 6, conn.ID()) { + _ = conn.Close() + return + } + t.TransportHandler.HandleTCP(conn) + }) +} + +func (t sourceTransport) HandleUDP(conn adapter.UDPConn) { + t.dispatch(conn, func() { + if !sourceAllowed(t.validator, 17, conn.ID()) { + _ = conn.Close() + return + } + // No positive cache: a closed Android UDP socket's port can belong to a different UID. + t.TransportHandler.HandleUDP(&sourceUDP{UDPConn: conn, validator: t.validator}) + }) +} + +type sourceUDP struct { + adapter.UDPConn + validator SourceValidator +} + +func (c *sourceUDP) Read(p []byte) (int, error) { + n, err := c.UDPConn.Read(p) + if (err == nil || n > 0) && !sourceAllowed(c.validator, 17, c.ID()) { + _ = c.Close() + return 0, errors.New("UDP source not authorized") + } + return n, err +} + +func (c *sourceUDP) ReadFrom(p []byte) (int, net.Addr, error) { + n, address, err := c.UDPConn.ReadFrom(p) + if (err == nil || n > 0) && !sourceAllowed(c.validator, 17, c.ID()) { + _ = c.Close() + return 0, nil, errors.New("UDP source not authorized") + } + return n, address, err +} + +// Selection happens only at startup. Disabled sessions retain both original objects. +func sourceStackConfig(endpoint stack.LinkEndpoint, handler adapter.TransportHandler, validator SourceValidator) *core.Config { + cfg := &core.Config{LinkEndpoint: endpoint, TransportHandler: handler} + if validator != nil { + cfg.TransportHandler = sourceTransport{TransportHandler: handler, validator: validator, slots: make(chan struct{}, 64)} + cfg.LinkEndpoint = &sourceLinkEndpoint{LinkEndpoint: endpoint} + } + return cfg +} diff --git a/native/mobile/source_validation_test.go b/native/mobile/source_validation_test.go new file mode 100644 index 00000000..3293905a --- /dev/null +++ b/native/mobile/source_validation_test.go @@ -0,0 +1,206 @@ +package mobile + +import ( + "net" + "testing" + + "github.com/xjasonlyu/tun2socks/v2/core/adapter" + "gvisor.dev/gvisor/pkg/tcpip" + "gvisor.dev/gvisor/pkg/tcpip/stack" +) + +type sourceTestValidator struct { + allowed bool + calls int + protocol int +} + +func (v *sourceTestValidator) Allow(protocol int, source string, sourcePort int, destination string, destinationPort int) bool { + v.calls++ + v.protocol = protocol + if source != "10.77.0.1" || sourcePort != 1234 || destination != "192.0.2.1" || destinationPort != 53 { + panic("incorrect Android socket orientation") + } + return v.allowed +} + +type sourceTestConn struct { + adapter.UDPConn + closed bool + zero bool + events chan struct{} +} + +func (c *sourceTestConn) ID() stack.TransportEndpointID { + return stack.TransportEndpointID{ + RemoteAddress: tcpip.AddrFrom4([4]byte{10, 77, 0, 1}), RemotePort: 1234, + LocalAddress: tcpip.AddrFrom4([4]byte{192, 0, 2, 1}), LocalPort: 53, + } +} +func (c *sourceTestConn) Close() error { + c.closed = true + if c.events != nil { + c.events <- struct{}{} + } + return nil +} +func (c *sourceTestConn) Read(p []byte) (int, error) { + if c.zero { + return 0, nil + } + return copy(p, "data"), nil +} +func (c *sourceTestConn) ReadFrom(p []byte) (int, net.Addr, error) { + n, e := c.Read(p) + return n, nil, e +} + +type sourceTestTransport struct { + tcp, udp int + conn adapter.UDPConn + events chan struct{} +} + +func (h *sourceTestTransport) HandleTCP(c adapter.TCPConn) { h.tcp++; h.events <- struct{}{} } +func (h *sourceTestTransport) HandleUDP(c adapter.UDPConn) { + h.udp++ + h.conn = c + h.events <- struct{}{} +} + +func TestSourceValidationAdmissionAndUDPPortReuse(t *testing.T) { + v := &sourceTestValidator{} + events := make(chan struct{}, 8) + handler := &sourceTestTransport{events: events} + guarded := sourceTransport{handler, v, make(chan struct{}, 64)} + tcp := &sourceTestConn{events: events} + guarded.HandleTCP(tcp) + <-events + if handler.tcp != 0 || !tcp.closed || v.protocol != 6 { + t.Fatal("denied TCP escaped") + } + udp := &sourceTestConn{events: events} + guarded.HandleUDP(udp) + <-events + if handler.udp != 0 || !udp.closed || v.protocol != 17 { + t.Fatal("denied UDP reached DNS/proxy") + } + v.allowed = true + guarded.HandleTCP(&sourceTestConn{}) + <-events + if handler.tcp != 1 { + t.Fatal("allowed TCP lost") + } + for _, zero := range []bool{false, true} { + for _, packet := range []bool{false, true} { + original := &sourceTestConn{zero: zero} + guarded.HandleUDP(original) + <-events + b := make([]byte, 16) + read := func() (int, error) { + if packet { + n, _, err := handler.conn.ReadFrom(b) + return n, err + } + return handler.conn.Read(b) + } + if _, err := read(); err != nil { + t.Fatal(err) + } + v.allowed = false // Same tuple, but Android now reports another/unknown owner. + if n, err := read(); n != 0 || err == nil || !original.closed { + t.Fatal("reused UDP source kept cached authorization") + } + v.allowed = true + } + } +} + +func TestSourceEchoPolicy(t *testing.T) { + v4 := func(kind byte) []byte { p := make([]byte, 28); p[0] = 0x45; p[9] = 1; p[20] = kind; return p } + v6 := func(kind byte) []byte { p := make([]byte, 48); p[0] = 0x60; p[6] = 58; p[40] = kind; return p } + for _, tc := range []struct { + name string + p []byte + blocked bool + }{ + {"v4 echo", v4(8), true}, {"v4 reply", v4(0), true}, {"v4 unreachable", v4(3), false}, + {"v6 echo", v6(128), true}, {"v6 reply", v6(129), true}, {"v6 packet too big", v6(2), false}, + {"truncated", []byte{0x60}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + if blockSourceEcho(tc.p) != tc.blocked { + t.Fatal("wrong ICMP policy") + } + }) + } + p := make([]byte, 56) + p[0] = 0x60 + p[6] = 60 + p[40] = 58 + p[48] = 128 + if !blockSourceEcho(p) { + t.Fatal("IPv6 extension bypass") + } + p[48] = 2 + if blockSourceEcho(p) { + t.Fatal("IPv6 errors blocked behind extension") + } + fragmented := make([]byte, 56) + fragmented[0] = 0x60 + fragmented[6] = 44 + fragmented[40] = 17 + if blockSourceEcho(fragmented) { + t.Fatal("fragmented UDP blocked before source validation") + } + fragmented[43] = 8 + if blockSourceEcho(fragmented) { + t.Fatal("non-initial UDP fragment blocked") + } + fragmented[40] = 58 + if !blockSourceEcho(fragmented) { + t.Fatal("fragmented Echo bypass") + } + fragmented[40] = 60 + if !blockSourceEcho(fragmented) { + t.Fatal("unattributable fragment bypass") + } + +} + +func TestDisabledSourceValidationKeepsOriginalDataPath(t *testing.T) { + endpoint := &sourceLinkEndpoint{} + handler := &sourceTestTransport{} + normal := sourceStackConfig(endpoint, handler, nil) + if normal.LinkEndpoint != endpoint || normal.TransportHandler != handler { + t.Fatal("disabled mode installed a data-path wrapper") + } + strict := sourceStackConfig(endpoint, handler, &sourceTestValidator{}) + if strict.LinkEndpoint == endpoint || strict.TransportHandler == handler { + t.Fatal("strict mode omitted protection") + } +} + +type sourceBlockingValidator struct{ entered, release chan struct{} } + +func (v sourceBlockingValidator) Allow(int, string, int, string, int) bool { + close(v.entered) + <-v.release + return true +} +func TestSourceLookupDoesNotBlockTUNAndAdmissionIsBounded(t *testing.T) { + events := make(chan struct{}, 2) + v := sourceBlockingValidator{make(chan struct{}), make(chan struct{})} + handler := &sourceTestTransport{events: events} + transport := sourceTransport{handler, v, make(chan struct{}, 1)} + transport.HandleTCP(&sourceTestConn{}) // Must return while the Android query is blocked. + <-v.entered + second := &sourceTestConn{events: events} + transport.HandleTCP(second) + <-events + if !second.closed { + t.Fatal("unbounded Android lookup queue") + } + close(v.release) + <-events +} diff --git a/scripts/android_device_tests.py b/scripts/android_device_tests.py index 8600cda8..b2e28424 100644 --- a/scripts/android_device_tests.py +++ b/scripts/android_device_tests.py @@ -477,6 +477,10 @@ def shell(*words): "Disposable emulator cannot reach the local SSH fixture" ) from None tests = [(name, None) for name in TESTS] + if args.api >= 29: + tests.append( + ("VpnDeviceTest#strictSourceValidatedTcpUdpStopAndRestart", None) + ) if args.api >= 33: tests.append(("VpnDeviceTest#deniedNotificationsStillAllowVpn", None)) tests += [ diff --git a/scripts/tests/test_android_device_tests.py b/scripts/tests/test_android_device_tests.py index 8cde69f7..a3fc1f51 100644 --- a/scripts/tests/test_android_device_tests.py +++ b/scripts/tests/test_android_device_tests.py @@ -27,6 +27,8 @@ def test_vpn_scenarios_are_registered(self): } # The runner selects this permission scenario separately on API >= 33. selected.add("deniedNotificationsStillAllowVpn") + # Strict source ownership uses Android API 29+ and is selected separately. + selected.add("strictSourceValidatedTcpUdpStopAndRestart") self.assertEqual(methods, selected) self.assertEqual(len(m.TESTS), len(set(m.TESTS))) From b2519dde5edec9568cb53ea6354151a51de8b712 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 27 Oct 2026 15:44:23 +0300 Subject: [PATCH 2/4] Move strict source validation into dedicated documentation --- README.md | 2 +- docs/en/dns.md | 24 ------------------------ docs/en/index.md | 1 + docs/en/source-validation.md | 25 +++++++++++++++++++++++++ docs/ru/dns.md | 24 ------------------------ docs/ru/index.md | 1 + docs/ru/source-validation.md | 25 +++++++++++++++++++++++++ 7 files changed, 53 insertions(+), 49 deletions(-) create mode 100644 docs/en/source-validation.md create mode 100644 docs/ru/source-validation.md diff --git a/README.md b/README.md index 53d7f029..1731af66 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ All guides: [English](docs/en/index.md) / [Русский](docs/ru/index.md). - HTTPS CONNECT over HTTP/1.1 or multiplexed HTTP/2; optional HTTP/3 preference with HTTPS fallback. - SOCKS5 TCP/UDP, experimental MASQUE TCP/UDP over HTTP/3 and SSH. - Per-app routing, local-network bypass, optional IPv6 and Android Always-on VPN. -- Optional [strict source validation](docs/en/dns.md#strict-source-validation) on Android 10+: app ownership checks and ping blocking, applied on reconnection. +- Optional [strict source validation](docs/en/source-validation.md) on Android 10+: app ownership checks and ping blocking, applied on reconnection. - Multiple profiles, import/export, HTTPS configuration subscriptions and automatic failover. - DNS-over-HTTPS, optional plain TCP/UDP DNS, selectable TLS/QUIC/SSH fingerprints and custom JA3. - Traffic totals/rates, passive TCP/QUIC metrics, connection tests, download/upload speed tests diff --git a/docs/en/dns.md b/docs/en/dns.md index 295b53c0..3775b62a 100644 --- a/docs/en/dns.md +++ b/docs/en/dns.md @@ -45,27 +45,3 @@ DoH alternatives start 200 ms apart; the first valid response wins and outstandi cancelled. Slow requests may therefore reach multiple configured providers. CUSTOM and filtering presets have no fallback providers. Proxy bootstrap uses the same strategy with its existing provider list, independently of profile DNS. Plain DNS has only the selected resolver. - -## Strict source validation - -In **Split tunneling**, optional **Strict source validation** requires Android 10+ and -applies when the VPN reconnects. It is local to the device, defaults to off and is not -included in profile exports or subscriptions. With it off, the native stack retains its -original transport and link handlers: no UID queries, validation caches or packet checks. - -When enabled, TCP ownership is checked before proxy/direct-LAN forwarding; UDP ownership is -checked on admission and for every outgoing datagram, including DNS, without a positive -cache that could outlive the Android socket. Unknown owners, lookup exceptions and stale -sessions are denied. Split mode accepts only the selected application UIDs; global mode -requires an identifiable owner. Packages sharing a UID cannot be distinguished. The -ownership lookup and forwarding are separate operations, not an atomic kernel guarantee. -Slow new-flow lookups run outside the shared TUN loop with at most 64 concurrent admissions; -excess attempts are closed. UDP checks add latency and CPU work and may disrupt apps or -traffic without a visible owner, such as tethering. No direct retry bypasses a denial. - -IPv4/IPv6 Echo (ping) is blocked before the userspace stack; ordinary ICMP errors retain -normal processing. Fragmented ICMP and IPv6 fragments with an unidentifiable transport are -also dropped. The physical network's ICMP is unaffected. The app's TCP/HTTP diagnostics -continue working. **Visibility** reports the active tunnel's protection and whether a -reconnect is needed. This is a session status, not an attack test from another application, -and does not hide VPN interfaces or other system-visible VPN signals. diff --git a/docs/en/index.md b/docs/en/index.md index 0f6e8480..c882073a 100644 --- a/docs/en/index.md +++ b/docs/en/index.md @@ -6,6 +6,7 @@ Server configurations and setup instructions have moved to the dedicated Client documentation: - [Configuration subscriptions](subscriptions.md) +- [Strict traffic source validation](source-validation.md) - [Installation](installation.md) - [App updates](updates.md) - [Connection metrics, Test, Speed and VPN visibility](connection-metrics.md) diff --git a/docs/en/source-validation.md b/docs/en/source-validation.md new file mode 100644 index 00000000..ff14201c --- /dev/null +++ b/docs/en/source-validation.md @@ -0,0 +1,25 @@ +# Strict source validation + +[Русский](../ru/source-validation.md) · [Documentation](index.md) + +In **Split tunneling**, optional **Strict source validation** requires Android 10+ and +applies when the VPN reconnects. It is local to the device, defaults to off and is not +included in profile exports or subscriptions. With it off, the native stack retains its +original transport and link handlers: no UID queries, validation caches or packet checks. + +When enabled, TCP ownership is checked before proxy/direct-LAN forwarding; UDP ownership is +checked on admission and for every outgoing datagram, including DNS, without a positive +cache that could outlive the Android socket. Unknown owners, lookup exceptions and stale +sessions are denied. Split mode accepts only the selected application UIDs; global mode +requires an identifiable owner. Packages sharing a UID cannot be distinguished. The +ownership lookup and forwarding are separate operations, not an atomic kernel guarantee. +Slow new-flow lookups run outside the shared TUN loop with at most 64 concurrent admissions; +excess attempts are closed. UDP checks add latency and CPU work and may disrupt apps or +traffic without a visible owner, such as tethering. No direct retry bypasses a denial. + +IPv4/IPv6 Echo (ping) is blocked before the userspace stack; ordinary ICMP errors retain +normal processing. Fragmented ICMP and IPv6 fragments with an unidentifiable transport are +also dropped. The physical network's ICMP is unaffected. The app's TCP/HTTP diagnostics +continue working. **Visibility** reports the active tunnel's protection and whether a +reconnect is needed. This is a session status, not an attack test from another application, +and does not hide VPN interfaces or other system-visible VPN signals. diff --git a/docs/ru/dns.md b/docs/ru/dns.md index 4e3c343a..f42b467a 100644 --- a/docs/ru/dns.md +++ b/docs/ru/dns.md @@ -48,27 +48,3 @@ TCP/UDP-адреса и не должны трактовать их как DoH. провайдерам. У CUSTOM и фильтрующих провайдеров фолбеков нет. Начальное разрешение адреса прокси использует тот же подход со своим списком провайдеров независимо от DNS профиля. У обычного DNS остаётся только выбранный сервер. - -## Строгая проверка источника - -В **Раздельном туннелировании** доступна опция **Строгая проверка источника** для Android 10+. -Применяется при переподключении VPN, по умолчанию выключена, хранится локально и не входит -в экспорт профилей и подписки. При выключенной опции нативный стек использует прежние -обработчики: без запросов UID, кешей проверки и дополнительных проверок пакетов. - -При включении владелец TCP проверяется до выхода через прокси или напрямую в LAN, UDP — -при создании потока и на каждой исходящей датаграмме, включая DNS, без положительного кеша, -способного пережить Android-сокет. Неизвестный владелец, ошибка API и устаревший сеанс -блокируются. В split-режиме разрешены только выбранные UID; в глобальном требуется -определимый владелец. Приложения с общим UID неразличимы. Запрос владельца и передача — -раздельные операции, а не атомарная гарантия ядра. Медленные проверки новых потоков идут -вне общего цикла TUN с ограничением 64 одновременных проверок; лишние попытки закрываются. -Проверки UDP увеличивают задержку и нагрузку и могут нарушать работу приложений и трафика -без определимого владельца, например раздачи интернета. Прямого обхода блокировки нет. - -Echo (ping) IPv4/IPv6 блокируется до userspace-стека; обычные ошибки ICMP обрабатываются -штатно. Фрагментированный ICMP и IPv6-фрагменты с неопределимым транспортом также блокируются. -ICMP физического соединения не затрагивается. TCP/HTTP-диагностика приложения продолжает -работать. **Проверка видимости** показывает защиту текущего туннеля и необходимость -переподключения. Это статус сеанса, а не проверка атаки из другого приложения; -он не скрывает VPN-интерфейсы и другие системные признаки VPN. diff --git a/docs/ru/index.md b/docs/ru/index.md index ab98e0ea..697a958d 100644 --- a/docs/ru/index.md +++ b/docs/ru/index.md @@ -6,6 +6,7 @@ Документация клиента: - [Подписка на конфигурацию](subscriptions.md) +- [Строгая проверка источника трафика](source-validation.md) - [DNS: режимы, маршрутизация и перенос настроек](dns.md) - [Установка](installation.md) - [Обновления приложения](updates.md) diff --git a/docs/ru/source-validation.md b/docs/ru/source-validation.md new file mode 100644 index 00000000..d626bf60 --- /dev/null +++ b/docs/ru/source-validation.md @@ -0,0 +1,25 @@ +# Строгая проверка источника + +[English](../en/source-validation.md) · [Документация](index.md) + +В **Раздельном туннелировании** доступна опция **Строгая проверка источника** для Android 10+. +Применяется при переподключении VPN, по умолчанию выключена, хранится локально и не входит +в экспорт профилей и подписки. При выключенной опции нативный стек использует прежние +обработчики: без запросов UID, кешей проверки и дополнительных проверок пакетов. + +При включении владелец TCP проверяется до выхода через прокси или напрямую в LAN, UDP — +при создании потока и на каждой исходящей датаграмме, включая DNS, без положительного кеша, +способного пережить Android-сокет. Неизвестный владелец, ошибка API и устаревший сеанс +блокируются. В split-режиме разрешены только выбранные UID; в глобальном требуется +определимый владелец. Приложения с общим UID неразличимы. Запрос владельца и передача — +раздельные операции, а не атомарная гарантия ядра. Медленные проверки новых потоков идут +вне общего цикла TUN с ограничением 64 одновременных проверок; лишние попытки закрываются. +Проверки UDP увеличивают задержку и нагрузку и могут нарушать работу приложений и трафика +без определимого владельца, например раздачи интернета. Прямого обхода блокировки нет. + +Echo (ping) IPv4/IPv6 блокируется до userspace-стека; обычные ошибки ICMP обрабатываются +штатно. Фрагментированный ICMP и IPv6-фрагменты с неопределимым транспортом также блокируются. +ICMP физического соединения не затрагивается. TCP/HTTP-диагностика приложения продолжает +работать. **Проверка видимости** показывает защиту текущего туннеля и необходимость +переподключения. Это статус сеанса, а не проверка атаки из другого приложения; +он не скрывает VPN-интерфейсы и другие системные признаки VPN. From e8dd8222b235c30dea2bab5fe1074d5905923773 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 27 Oct 2026 16:02:44 +0300 Subject: [PATCH 3/4] Add standard and advanced source validation levels --- AGENTS.md | 8 ++- .../java/net/megaproxy487/VpnDeviceTest.kt | 8 ++- .../java/net/megaproxy487/SourceProtection.kt | 6 +- .../net/megaproxy487/SplitTunnelScreen.kt | 22 +++++++ .../java/net/megaproxy487/VisibilityScreen.kt | 10 ++-- .../java/net/megaproxy487/data/ConfigEdits.kt | 1 + .../java/net/megaproxy487/data/ConfigStore.kt | 6 ++ .../net/megaproxy487/data/ConfigTransfer.kt | 2 +- .../net/megaproxy487/model/ProxyConfig.kt | 8 +++ .../java/net/megaproxy487/vpn/ProxyCore.kt | 2 +- .../net/megaproxy487/vpn/ProxyVpnService.kt | 7 ++- app/src/main/res/values-ru/strings.xml | 10 +++- app/src/main/res/values/strings.xml | 10 +++- .../java/net/megaproxy487/ConfigEditsTest.kt | 2 +- .../net/megaproxy487/SourceProtectionTest.kt | 22 ++++--- .../StrictSourceSettingsUiTest.kt | 21 +++++++ docs/en/release-testing.md | 4 +- docs/en/source-validation.md | 34 ++++++++--- docs/ru/release-testing.md | 5 +- docs/ru/source-validation.md | 36 ++++++++--- native/mobile/mobile.go | 6 +- native/mobile/source_validation.go | 60 +++++++++++++++---- native/mobile/source_validation_test.go | 48 +++++++++++++-- 23 files changed, 268 insertions(+), 70 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f480e7d4..ab4d78d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -169,9 +169,11 @@ branch names, credentials, signing material, or other secrets. - Strict source validation is device-local, opt-in and Android 10+; apply on reconnection. Disabled sessions retain the original native data path. Enabled sessions fail closed on - unknown/stale owners, validate TCP admission and every outgoing UDP datagram without positive - UID caching, and block TUN Echo while preserving ordinary ICMP error handling. Visibility - reports the active session, not proof of resistance to a separate-app bypass test. + unknown owners on lookup, validate TCP admission, and block TUN Echo while preserving ordinary + ICMP error handling. Standard caches UDP admission per live flow for a fixed non-sliding second; + Advanced checks every outgoing datagram. New opt-in defaults to Standard; legacy enabled settings + migrate to Advanced. Keep both levels local and preserve them across imports. Visibility reports + the actual running level and pending changes, not proof of a separate-app bypass test. ## Architecture landmarks diff --git a/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt b/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt index e4577606..870c03ae 100644 --- a/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt +++ b/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt @@ -9,6 +9,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import net.megaproxy487.model.FailoverMode import net.megaproxy487.model.TlsProfile import net.megaproxy487.model.ProxyType +import net.megaproxy487.model.SourceValidationLevel import net.megaproxy487.vpn.ConnectionStatsReader import net.megaproxy487.vpn.PersistentDiagnosticLog import net.megaproxy487.vpn.VpnTransportProtocol @@ -29,10 +30,10 @@ class VpnDeviceTest : DeviceTestBase() { @Test fun strictSourceValidatedTcpUdpStopAndRestart() { org.junit.Assume.assumeTrue(Build.VERSION.SDK_INT >= 29) - socks5Traffic(true, strict = true) + SourceValidationLevel.entries.forEach { socks5Traffic(true, strict = true, level = it) } } - private fun socks5Traffic(authenticated: Boolean, strict: Boolean = false) { + private fun socks5Traffic(authenticated: Boolean, strict: Boolean = false, level: SourceValidationLevel = SourceValidationLevel.STANDARD) { io { val profile = store.activeProfile() store.saveProfile(profile.copy(config = profile.config.copy( @@ -42,13 +43,14 @@ class VpnDeviceTest : DeviceTestBase() { password = if (authenticated) argument("proxyPassword") else "", ))) store.saveGlobalConnectionSettings(store.globalConnectionSettings().copy( - routeAllApps = false, selectedPackages = setOf(context.packageName), bypassLocalNetworks = false, strictSourceValidation = strict)) + routeAllApps = false, selectedPackages = setOf(context.packageName), bypassLocalNetworks = false, strictSourceValidation = strict, sourceValidationLevel = level)) } saved() directOriginUnavailable() repeat(2) { connect() assertEquals(strict, ProxyVpnService.strictSourceValidationActive) + assertEquals(if (strict) level else null, ProxyVpnService.sourceValidationLevelActive) roundTrip() listOf(1200, 1350, 4096).forEach(::udpRoundTrip) await("SOCKS5 protocol indicator did not update") { VpnRuntimeState.transportProtocol.value == VpnTransportProtocol.SOCKS5 } diff --git a/app/src/main/java/net/megaproxy487/SourceProtection.kt b/app/src/main/java/net/megaproxy487/SourceProtection.kt index d561fe10..f5c61510 100644 --- a/app/src/main/java/net/megaproxy487/SourceProtection.kt +++ b/app/src/main/java/net/megaproxy487/SourceProtection.kt @@ -1,11 +1,13 @@ package net.megaproxy487 +import net.megaproxy487.model.SourceValidationLevel + internal enum class SourceProtectionStatus { UNSUPPORTED, STOPPED, ACTIVE, DISABLED, RECONNECT } -internal fun sourceProtectionStatus(sdk: Int, configured: Boolean, running: Boolean, active: Boolean): SourceProtectionStatus = when { +internal fun sourceProtectionStatus(sdk: Int, configured: SourceValidationLevel?, running: Boolean, active: SourceValidationLevel?): SourceProtectionStatus = when { sdk < 29 -> SourceProtectionStatus.UNSUPPORTED !running -> SourceProtectionStatus.STOPPED configured != active -> SourceProtectionStatus.RECONNECT - active -> SourceProtectionStatus.ACTIVE + active != null -> SourceProtectionStatus.ACTIVE else -> SourceProtectionStatus.DISABLED } diff --git a/app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt b/app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt index 107e550c..704a03a2 100644 --- a/app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt +++ b/app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt @@ -228,6 +228,28 @@ internal fun SplitTunnelScreen(activity: Activity, onBack: () -> Unit) { Switch(settings.strictSourceValidation, null, enabled = supported || settings.strictSourceValidation) } } + if (settings.strictSourceValidation && android.os.Build.VERSION.SDK_INT >= 29) { + item { + Column(Modifier.selectableGroup()) { + net.megaproxy487.model.SourceValidationLevel.entries.forEach { level -> + Row( + Modifier.fillMaxWidth().heightIn(min = 56.dp).selectable( + selected = settings.sourceValidationLevel == level, + onClick = { updateSettings(settings.copy(sourceValidationLevel = level)) }, + role = Role.RadioButton, + ), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + RadioButton(settings.sourceValidationLevel == level, null) + Column(Modifier.weight(1f)) { + Text(stringResource(level.labelResource)) + Text(stringResource(if (level == net.megaproxy487.model.SourceValidationLevel.STANDARD) R.string.source_level_standard_detail else R.string.source_level_advanced_detail), style = MaterialTheme.typography.bodySmall) + } + } + } + } + } + } if (!settings.routeAllApps) { item { val checked = activity.packageName in settings.selectedPackages diff --git a/app/src/main/java/net/megaproxy487/VisibilityScreen.kt b/app/src/main/java/net/megaproxy487/VisibilityScreen.kt index 380eecfd..f026026a 100644 --- a/app/src/main/java/net/megaproxy487/VisibilityScreen.kt +++ b/app/src/main/java/net/megaproxy487/VisibilityScreen.kt @@ -318,7 +318,7 @@ private fun buildVisibilityReport(context: Context): VisibilityReport { vpnCheck(context.uiText(R.string.virtual_default_route), virtualDefaultRoute, context.uiText(R.string.virtual_route_yes), context.uiText(R.string.virtual_route_no)), vpnCheck(context.uiText(R.string.virtual_dns_address), virtualDns, context.uiText(R.string.virtual_dns_yes), context.uiText(R.string.virtual_dns_no)), ), - sourceProtection = sourceProtectionCheck(context, settings.strictSourceValidation), + sourceProtection = sourceProtectionCheck(context, settings.sourceValidationLevel.takeIf { settings.strictSourceValidation }), splitTunneling = splitTunneling, ) } @@ -327,15 +327,15 @@ private const val PROXYCHECK_LOOKUP_URL = "https://proxycheck.io/v3/" private const val PROXYCHECK_CACHE = "proxycheck_visibility_cache" private const val PROXYCHECK_CACHE_TTL_MS = 24 * 60 * 60 * 1000L -private fun sourceProtectionCheck(context: Context, configured: Boolean): VisibilityCheck { - val active = net.megaproxy487.vpn.ProxyVpnService.strictSourceValidationActive +private fun sourceProtectionCheck(context: Context, configured: net.megaproxy487.model.SourceValidationLevel?): VisibilityCheck { + val active = net.megaproxy487.vpn.ProxyVpnService.sourceValidationLevelActive val status = sourceProtectionStatus(Build.VERSION.SDK_INT, configured, net.megaproxy487.vpn.ProxyVpnService.isRunning, active) val (state, detail) = when (status) { SourceProtectionStatus.UNSUPPORTED -> VisibilityState.UNAVAILABLE to R.string.strict_source_unsupported SourceProtectionStatus.STOPPED -> VisibilityState.UNAVAILABLE to R.string.strict_source_stopped - SourceProtectionStatus.ACTIVE -> VisibilityState.ACTIVE to R.string.strict_source_active_detail + SourceProtectionStatus.ACTIVE -> VisibilityState.ACTIVE to if (active == net.megaproxy487.model.SourceValidationLevel.STANDARD) R.string.source_standard_active_detail else R.string.strict_source_active_detail SourceProtectionStatus.DISABLED -> VisibilityState.DISABLED to R.string.strict_source_disabled_detail - SourceProtectionStatus.RECONNECT -> VisibilityState.UNKNOWN to if (active) R.string.strict_source_pending_disable else R.string.strict_source_pending_enable + SourceProtectionStatus.RECONNECT -> VisibilityState.UNKNOWN to if (active == null) R.string.strict_source_pending_enable else if (configured == null) R.string.strict_source_pending_disable else R.string.source_level_pending } return VisibilityCheck(context.uiText(R.string.strict_source_title), state, context.uiText(detail)) } diff --git a/app/src/main/java/net/megaproxy487/data/ConfigEdits.kt b/app/src/main/java/net/megaproxy487/data/ConfigEdits.kt index 86bddd3a..ee394592 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigEdits.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigEdits.kt @@ -89,6 +89,7 @@ internal fun ConfigEdits.profile(before: ProxyProfile, after: Prox internal fun ConfigEdits.settings(before: GlobalConnectionSettings, after: GlobalConnectionSettings): ConfigEdit { record("mtu", before.mtu, after.mtu) { it.copy(mtu = after.mtu) } + record("sourceValidationLevel", before.sourceValidationLevel, after.sourceValidationLevel) { it.copy(sourceValidationLevel = after.sourceValidationLevel) } record("strictSourceValidation", before.strictSourceValidation, after.strictSourceValidation) { it.copy(strictSourceValidation = after.strictSourceValidation) } record("tlsProfile", before.tlsProfile, after.tlsProfile) { it.copy(tlsProfile = after.tlsProfile) } record("sshProfile", before.sshProfile, after.sshProfile) { it.copy(sshProfile = after.sshProfile) } diff --git a/app/src/main/java/net/megaproxy487/data/ConfigStore.kt b/app/src/main/java/net/megaproxy487/data/ConfigStore.kt index e70947a8..fca0b989 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigStore.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigStore.kt @@ -771,6 +771,7 @@ class ConfigStore(context: Context) { put("bypassLocalNetworks", settings.bypassLocalNetworks) put("mtu", settings.mtu) put("strictSourceValidation", settings.strictSourceValidation) + put("sourceValidationLevel", settings.sourceValidationLevel.name) }.toString() private fun decodeGlobalConnectionSettings(value: String): GlobalConnectionSettings = operationResult { @@ -793,6 +794,11 @@ class ConfigStore(context: Context) { GlobalConnectionSettings( mtu = mtu, strictSourceValidation = item.optBoolean("strictSourceValidation", false), + sourceValidationLevel = if (item.has("sourceValidationLevel")) { + net.megaproxy487.model.SourceValidationLevel.valueOf(item.getString("sourceValidationLevel")) + } else if (item.optBoolean("strictSourceValidation", false)) { + net.megaproxy487.model.SourceValidationLevel.ADVANCED + } else net.megaproxy487.model.SourceValidationLevel.STANDARD, tlsProfile = parsedTls.takeIf { it.available } ?: TlsProfile.DEFAULT, sshProfile = enumValue(item.optString("sshProfile"), SshProfile.DEFAULT), sshAuthMode = enumValue(item.optString("sshAuthMode"), SshAuthMode.AUTO), diff --git a/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt b/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt index bc23907b..b7f5de67 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt @@ -39,7 +39,7 @@ data class PortableConfiguration( ) { internal fun mergeGlobalSettings(current: GlobalConnectionSettings): GlobalConnectionSettings { val incoming = globalConnectionSettings ?: return current - val fields = globalSettingFields ?: return incoming.copy(mtu = current.mtu, strictSourceValidation = current.strictSourceValidation) + val fields = globalSettingFields ?: return incoming.copy(mtu = current.mtu, strictSourceValidation = current.strictSourceValidation, sourceValidationLevel = current.sourceValidationLevel) return current.copy( tlsProfile = if ("tls.fingerprint" in fields) incoming.tlsProfile else current.tlsProfile, customJa3 = if ("tls.customJa3" in fields) incoming.customJa3 else current.customJa3, diff --git a/app/src/main/java/net/megaproxy487/model/ProxyConfig.kt b/app/src/main/java/net/megaproxy487/model/ProxyConfig.kt index a54c5aa2..45188387 100644 --- a/app/src/main/java/net/megaproxy487/model/ProxyConfig.kt +++ b/app/src/main/java/net/megaproxy487/model/ProxyConfig.kt @@ -7,6 +7,11 @@ import net.megaproxy487.R const val DEFAULT_VPN_MTU = 1400 val VPN_MTU_RANGE = 1280..65535 +enum class SourceValidationLevel(val udpRecheckMillis: Long, @StringRes val labelResource: Int) { + STANDARD(1000, R.string.source_level_standard), + ADVANCED(0, R.string.source_level_advanced), +} + data class ProxyConfig( val type: ProxyType = ProxyType.HTTPS, val host: String = "", @@ -28,6 +33,7 @@ data class ProxyConfig( val bypassLocalNetworks: Boolean = true, val mtu: Int = DEFAULT_VPN_MTU, val strictSourceValidation: Boolean = false, + val sourceValidationLevel: SourceValidationLevel = SourceValidationLevel.STANDARD, val resolvedProxyIp: String = "", val privateKey: String = "", val sshProfile: SshProfile = SshProfile.DEFAULT, @@ -229,6 +235,7 @@ data class GlobalConnectionSettings( val bypassLocalNetworks: Boolean = true, val mtu: Int = DEFAULT_VPN_MTU, val strictSourceValidation: Boolean = false, + val sourceValidationLevel: SourceValidationLevel = SourceValidationLevel.STANDARD, ) { fun applyTo(config: ProxyConfig): ProxyConfig = config.copy( profile = if (tlsProfile == TlsProfile.DEFAULT) TlsProfile.CHROME_ANDROID else tlsProfile, @@ -244,6 +251,7 @@ data class GlobalConnectionSettings( bypassLocalNetworks = bypassLocalNetworks, mtu = mtu, strictSourceValidation = strictSourceValidation, + sourceValidationLevel = sourceValidationLevel, ) } diff --git a/app/src/main/java/net/megaproxy487/vpn/ProxyCore.kt b/app/src/main/java/net/megaproxy487/vpn/ProxyCore.kt index b10da4f4..81472426 100644 --- a/app/src/main/java/net/megaproxy487/vpn/ProxyCore.kt +++ b/app/src/main/java/net/megaproxy487/vpn/ProxyCore.kt @@ -159,7 +159,7 @@ class NativeProxyCore( java.net.InetSocketAddress(destination, destinationPort)) }, ) - Mobile.startStrict(borrowed.fd.toLong(), mtu.toLong(), json, protector(), reporter, validator) + Mobile.startStrict(borrowed.fd.toLong(), mtu.toLong(), json, protector(), reporter, validator, config.sourceValidationLevel.udpRecheckMillis) } else { Mobile.start(borrowed.fd.toLong(), mtu.toLong(), json, protector(), reporter) } diff --git a/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt b/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt index f66a2f87..2d1fc478 100644 --- a/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt +++ b/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt @@ -521,7 +521,7 @@ class ProxyVpnService : VpnService() { core = proxyCore tunnelTestOnly = testOnly activeSession = ConnectionTestTarget(promptProfileId, config) - strictSourceValidationActive = config.strictSourceValidation + sourceValidationLevelActive = config.sourceValidationLevel.takeIf { config.strictSourceValidation } isRunning = true true } @@ -864,7 +864,7 @@ class ProxyVpnService : VpnService() { tunnelTestOnly = false activeSession = null isRunning = false - strictSourceValidationActive = false + sourceValidationLevelActive = null result } SubscriptionNotifications.cancel(this) @@ -971,8 +971,9 @@ class ProxyVpnService : VpnService() { .build() companion object { - @Volatile var strictSourceValidationActive: Boolean = false + @Volatile var sourceValidationLevelActive: net.megaproxy487.model.SourceValidationLevel? = null private set + val strictSourceValidationActive: Boolean get() = sourceValidationLevelActive != null private val serviceThread = HandlerThread("megaproxy-vpn-control").apply { start() } private const val CHANNEL_ID = "vpn" private const val NOTIFICATION_ID = 101 diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 5edaf482..f13a8737 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -590,14 +590,20 @@ Для всех профилей на этом устройстве, после переподключения. До 7 HTTPS URL, по одному на строку. Строгая проверка источника - Проверять приложение перед передачей TCP и каждой UDP-датаграммы. Блокирует неизвестных владельцев и ping; может замедлить UDP или блокировать приложения. Применяется при переподключении. + Проверяет приложение-источник TCP и UDP, блокирует неизвестных владельцев и ping. Может замедлить установление TCP-соединений и передачу UDP, нарушить работу приложений. Применяется при переподключении. Требуется Android 10 или новее. Защита источника трафика Работает Выключена VPN не запущен. Подключитесь, чтобы проверить работающую защиту. - Текущий туннель проверяет TCP и каждую исходящую UDP-датаграмму и блокирует ping. Это статус сеанса, а не тест обхода из другого приложения. Наличие VPN по-прежнему можно обнаружить. + Расширенный уровень: текущий туннель проверяет TCP при создании потока и каждую исходящую UDP-датаграмму, блокирует ping. Это статус сеанса, а не тест обхода другим приложением; он не скрывает наличие VPN. Текущий туннель использует маршрутизацию приложений Android без дополнительной проверки источника. Защита выбрана, но не работает в текущем туннеле. Переподключитесь для включения. Защита ещё работает в текущем туннеле. Переподключитесь для выключения. + Стандартный + Расширенный + Проверяет новые UDP-потоки и повторяет проверку при передаче не реже раза в секунду. Меньше нагрузка; повторно использованный порт может оставаться разрешённым до следующей проверки. + Проверяет каждую исходящую UDP-датаграмму. Быстрее обнаруживает смену владельца; выше задержка и нагрузка. + Стандартный уровень: текущий туннель проверяет TCP при создании потока и кеширует разрешение UDP на срок до 1 секунды для каждого потока. Повторно использованный порт может временно пропускаться. Ping блокируется. Это статус сеанса, а не тест обхода другим приложением; он не скрывает наличие VPN. + Текущий туннель ещё использует прежний уровень проверки. Переподключитесь для применения выбранного уровня. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9a12e136..b0200004 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -591,14 +591,20 @@ Applies to all profiles on this device after reconnection. Up to 7 HTTPS URLs, one per line. Strict source validation - Verify app ownership before forwarding TCP and every UDP datagram. Blocks unknown owners and ping; may slow UDP or block apps. Applies on reconnection. + Checks TCP and UDP app ownership; blocks unknown owners and ping. May delay TCP connection setup, slow UDP or disrupt apps. Applies on reconnection. Requires Android 10 or later. Traffic source protection Active Disabled VPN is not running. Connect to check the active protection. - The current tunnel validates TCP and each outgoing UDP datagram and blocks ping. This is a session status, not a bypass test from another app. VPN presence can still be detected. + Advanced level: the current tunnel checks TCP admission and every outgoing UDP datagram and blocks ping. This session status is not a separate-app bypass test and does not hide VPN presence. The current tunnel uses Android app routing without additional source validation. Protection is selected but is not active in the current tunnel. Reconnect to enable it. Protection is still active in the current tunnel. Reconnect to disable it. + Standard + Advanced + Checks new UDP flows and rechecks on traffic at least once per second. Less overhead; a reused port may remain allowed until the next check. + Checks every outgoing UDP datagram. Detects owner changes sooner; higher latency and CPU usage. + Standard level: the current tunnel checks TCP admission and caches UDP permission for up to 1 second per flow. A reused port may be temporarily accepted. Ping is blocked. This session status is not a separate-app bypass test and does not hide VPN presence. + The current tunnel still uses the previous validation level. Reconnect to apply the selected level. diff --git a/app/src/test/java/net/megaproxy487/ConfigEditsTest.kt b/app/src/test/java/net/megaproxy487/ConfigEditsTest.kt index e32b1e83..e5bf2cf8 100644 --- a/app/src/test/java/net/megaproxy487/ConfigEditsTest.kt +++ b/app/src/test/java/net/megaproxy487/ConfigEditsTest.kt @@ -44,7 +44,7 @@ class ConfigEditsTest { @Test fun globalFieldEditsPreserveNewRoutingFailoverAndFingerprintValues() { val initial = GlobalConnectionSettings() val remote = initial.copy(tlsProfile = TlsProfile.FIREFOX_ANDROID, bypassLocalNetworks = false, - failoverMode = FailoverMode.ALL, customJa3 = "remote", selectedPackages = setOf("new.package"), mtu = 1420, strictSourceValidation = true) + failoverMode = FailoverMode.ALL, customJa3 = "remote", selectedPackages = setOf("new.package"), mtu = 1420, strictSourceValidation = true, sourceValidationLevel = SourceValidationLevel.ADVANCED) var saved = remote ConfigEdits().settings(initial, initial.copy(sshKeepaliveSeconds = 60)) .write { saved = it(saved) } diff --git a/app/src/test/java/net/megaproxy487/SourceProtectionTest.kt b/app/src/test/java/net/megaproxy487/SourceProtectionTest.kt index 38899003..1f59f59c 100644 --- a/app/src/test/java/net/megaproxy487/SourceProtectionTest.kt +++ b/app/src/test/java/net/megaproxy487/SourceProtectionTest.kt @@ -1,6 +1,7 @@ package net.megaproxy487 import net.megaproxy487.vpn.BridgeSourceValidator +import net.megaproxy487.model.SourceValidationLevel import org.junit.Assert.* import org.junit.Test @@ -36,20 +37,27 @@ class SourceProtectionTest { } @Test fun profileImportsCannotDisableLocalProtection() { - val current = net.megaproxy487.model.GlobalConnectionSettings(strictSourceValidation = true) + val current = net.megaproxy487.model.GlobalConnectionSettings(strictSourceValidation = true, sourceValidationLevel = SourceValidationLevel.ADVANCED) for (fields in listOf?>(null, emptySet(), setOf("routing.routeAllApps"))) { val imported = net.megaproxy487.data.PortableConfiguration(emptyList(), null, null, globalConnectionSettings = net.megaproxy487.model.GlobalConnectionSettings(), globalSettingFields = fields) assertTrue(imported.mergeGlobalSettings(current).strictSourceValidation) + assertEquals(SourceValidationLevel.ADVANCED, imported.mergeGlobalSettings(current).sourceValidationLevel) } } + @Test fun changingActiveLevelRequiresReconnection() { + assertEquals(SourceProtectionStatus.RECONNECT, sourceProtectionStatus(35, SourceValidationLevel.ADVANCED, true, SourceValidationLevel.STANDARD)) + assertEquals(SourceProtectionStatus.RECONNECT, sourceProtectionStatus(35, SourceValidationLevel.STANDARD, true, SourceValidationLevel.ADVANCED)) + assertEquals(SourceProtectionStatus.ACTIVE, sourceProtectionStatus(35, SourceValidationLevel.ADVANCED, true, SourceValidationLevel.ADVANCED)) + } + @Test fun visibilityDescribesRunningSessionRatherThanJustPreference() { - assertEquals(SourceProtectionStatus.UNSUPPORTED, sourceProtectionStatus(26, true, true, false)) - assertEquals(SourceProtectionStatus.STOPPED, sourceProtectionStatus(35, true, false, false)) - assertEquals(SourceProtectionStatus.DISABLED, sourceProtectionStatus(35, false, true, false)) - assertEquals(SourceProtectionStatus.RECONNECT, sourceProtectionStatus(35, true, true, false)) - assertEquals(SourceProtectionStatus.RECONNECT, sourceProtectionStatus(35, false, true, true)) - assertEquals(SourceProtectionStatus.ACTIVE, sourceProtectionStatus(35, true, true, true)) + assertEquals(SourceProtectionStatus.UNSUPPORTED, sourceProtectionStatus(26, SourceValidationLevel.STANDARD, true, null)) + assertEquals(SourceProtectionStatus.STOPPED, sourceProtectionStatus(35, SourceValidationLevel.STANDARD, false, null)) + assertEquals(SourceProtectionStatus.DISABLED, sourceProtectionStatus(35, null, true, null)) + assertEquals(SourceProtectionStatus.RECONNECT, sourceProtectionStatus(35, SourceValidationLevel.STANDARD, true, null)) + assertEquals(SourceProtectionStatus.RECONNECT, sourceProtectionStatus(35, null, true, SourceValidationLevel.STANDARD)) + assertEquals(SourceProtectionStatus.ACTIVE, sourceProtectionStatus(35, SourceValidationLevel.STANDARD, true, SourceValidationLevel.STANDARD)) } } diff --git a/app/src/test/java/net/megaproxy487/StrictSourceSettingsUiTest.kt b/app/src/test/java/net/megaproxy487/StrictSourceSettingsUiTest.kt index 488f2a20..eee849f2 100644 --- a/app/src/test/java/net/megaproxy487/StrictSourceSettingsUiTest.kt +++ b/app/src/test/java/net/megaproxy487/StrictSourceSettingsUiTest.kt @@ -2,6 +2,7 @@ package net.megaproxy487 import androidx.compose.ui.test.* import net.megaproxy487.data.ConfigStore +import net.megaproxy487.model.SourceValidationLevel import org.junit.Assert.* import org.junit.Test @@ -14,6 +15,20 @@ class StrictSourceSettingsUiTest : MainUiTestBase() { assertFalse(store.globalConnectionSettings().strictSourceValidation) } + @Test fun legacyEnabledSettingKeepsAdvancedAndInvalidLevelsPreserveStorage() { + val prefs = activity.getSharedPreferences("proxy_config", android.content.Context.MODE_PRIVATE) + val key = "global_connection_settings_v1" + val json = org.json.JSONObject(prefs.getString(key, null)!!) + json.put("strictSourceValidation", true).remove("sourceValidationLevel") + prefs.edit().putString(key, json.toString()).commit() + assertEquals(SourceValidationLevel.ADVANCED, ConfigStore(activity).globalConnectionSettings().sourceValidationLevel) + json.put("sourceValidationLevel", "unknown") + val malformed = json.toString() + prefs.edit().putString(key, malformed).commit() + assertThrows(UiException::class.java) { ConfigStore(activity).globalConnectionSettings() } + assertEquals(malformed, prefs.getString(key, null)) + } + @Test fun protectionIsOptInAndPersistsAsLocalSetting() { assertFalse(store.globalConnectionSettings().strictSourceValidation) content { SplitTunnelScreen(activity) {} } @@ -22,6 +37,12 @@ class StrictSourceSettingsUiTest : MainUiTestBase() { saved() assertTrue(ConfigStore(activity).globalConnectionSettings().strictSourceValidation) assertTrue(ConfigStore(activity).effectiveConnectionProfile().config.strictSourceValidation) + assertEquals(SourceValidationLevel.STANDARD, ConfigStore(activity).globalConnectionSettings().sourceValidationLevel) + node(R.string.source_level_standard).assertIsSelected() + node(R.string.source_level_advanced).performClick() + saved() + assertEquals(SourceValidationLevel.ADVANCED, ConfigStore(activity).globalConnectionSettings().sourceValidationLevel) + assertEquals(SourceValidationLevel.ADVANCED, ConfigStore(activity).effectiveConnectionProfile().config.sourceValidationLevel) node(R.string.strict_source_title).performClick() saved() assertFalse(ConfigStore(activity).globalConnectionSettings().strictSourceValidation) diff --git a/docs/en/release-testing.md b/docs/en/release-testing.md index ce7ef2cd..4d4b2032 100644 --- a/docs/en/release-testing.md +++ b/docs/en/release-testing.md @@ -134,7 +134,7 @@ and scrolling as well. In short windows the app bar disappears while typing; not move into the scrollable form. Orientation and font arguments affect only the test Activity. Afterward uninstall only `net.megaproxy487.keyboardreview.test` and `net.megaproxy487.keyboardreview`. For strict source validation changes, check opt-in persistence and pending reconnect status, -API 26 unavailability, and API 35 real TCP/UDP/DNS through JNI. Check unknown/stale UID denial, -UDP source-port reuse, both Echo families and preserved MTU errors with deterministic native/JVM +API 26 unavailability, and API 35 real TCP/UDP/DNS through JNI at both levels. Check unknown/stale UID denial, +UDP source-port reuse (immediate denial in Advanced; fixed non-sliding 1-second window in Standard), both Echo families and preserved MTU errors with deterministic native/JVM tests. Verify disabled sessions install no filtering wrappers. A visibility status is not a separate-UID bypass test; use a controlled excluded helper app for manual attack reproduction. diff --git a/docs/en/source-validation.md b/docs/en/source-validation.md index ff14201c..cce1f3dd 100644 --- a/docs/en/source-validation.md +++ b/docs/en/source-validation.md @@ -7,15 +7,31 @@ applies when the VPN reconnects. It is local to the device, defaults to off and included in profile exports or subscriptions. With it off, the native stack retains its original transport and link handlers: no UID queries, validation caches or packet checks. -When enabled, TCP ownership is checked before proxy/direct-LAN forwarding; UDP ownership is -checked on admission and for every outgoing datagram, including DNS, without a positive -cache that could outlive the Android socket. Unknown owners, lookup exceptions and stale -sessions are denied. Split mode accepts only the selected application UIDs; global mode -requires an identifiable owner. Packages sharing a UID cannot be distinguished. The -ownership lookup and forwarding are separate operations, not an atomic kernel guarantee. -Slow new-flow lookups run outside the shared TUN loop with at most 64 concurrent admissions; -excess attempts are closed. UDP checks add latency and CPU work and may disrupt apps or -traffic without a visible owner, such as tethering. No direct retry bypasses a denial. +Both levels check TCP ownership once before proxy/direct-LAN forwarding, adding latency +when a connection is established, rather than repeating ownership checks on TCP data. +UDP, including DNS, is checked at admission. Choose the UDP policy: + +| Level | UDP ownership checks | Tradeoff | +| --- | --- | --- | +| Standard (initial choice) | Admission and the first outgoing datagram after a fixed 1-second interval | Fewer Android API calls; a reused port may temporarily inherit an old permission | +| Advanced | Admission and every outgoing datagram | Earlier detection of owner changes; more CPU work and latency | + +The Standard cache belongs only to the specific live flow. It is not shared by source port, +destination or subsequent flows. Cache hits do not extend the deadline; no background timer +is needed. Expired permission must be rechecked before forwarding the next datagram. A denied +flow is closed. This deliberately allows a stale-owner window of up to 1 second between +checks and does not offer the same protection as Advanced. Neither level makes ownership +lookup and forwarding an atomic kernel operation. + +Unknown owners and lookup exceptions are denied when checked. Split mode accepts only the +selected application UIDs; global mode requires an identifiable owner. Packages sharing a UID +cannot be distinguished. Slow new-flow lookups run outside the shared TUN loop with at most +64 concurrent admissions; excess attempts are closed. Both levels may disrupt apps or traffic +without a visible owner, such as tethering. No direct retry bypasses a denial. + +The selected level is also device-local and applies on reconnection. Existing enabled settings +without a level migrate to Advanced, preserving their previous behavior; fresh opt-in uses +Standard. Visibility reports the actual running level, including when a level change is pending. IPv4/IPv6 Echo (ping) is blocked before the userspace stack; ordinary ICMP errors retain normal processing. Fragmented ICMP and IPv6 fragments with an unidentifiable transport are diff --git a/docs/ru/release-testing.md b/docs/ru/release-testing.md index 8f253d9b..d50219ae 100644 --- a/docs/ru/release-testing.md +++ b/docs/ru/release-testing.md @@ -137,8 +137,9 @@ adb -s SERIAL shell am instrument -w -r -e class net.megaproxy487.KeyboardInsets и `net.megaproxy487.keyboardreview`. При изменении строгой проверки источника проверьте сохранение opt-in и статус до -переподключения, недоступность на API 26 и реальный TCP/UDP/DNS через JNI на API 35. -Блокировку неизвестного/устаревшего UID, повторное использование UDP-порта, Echo обоих +переподключения, недоступность на API 26 и реальный TCP/UDP/DNS через JNI на API 35 для обоих уровней. +Блокировку неизвестного/устаревшего UID, повторное использование UDP-порта (немедленный отказ на расширенном уровне, фиксированное +окно 1 секунду без продления на стандартном), Echo обоих семейств и сохранение ошибок MTU проверяют детерминированные native/JVM-тесты. Выключенный режим не устанавливает фильтрующие обёртки. Статус видимости не заменяет тест обхода другим UID; для ручного воспроизведения используйте контролируемое исключённое приложение. diff --git a/docs/ru/source-validation.md b/docs/ru/source-validation.md index d626bf60..e9ad294d 100644 --- a/docs/ru/source-validation.md +++ b/docs/ru/source-validation.md @@ -7,15 +7,33 @@ в экспорт профилей и подписки. При выключенной опции нативный стек использует прежние обработчики: без запросов UID, кешей проверки и дополнительных проверок пакетов. -При включении владелец TCP проверяется до выхода через прокси или напрямую в LAN, UDP — -при создании потока и на каждой исходящей датаграмме, включая DNS, без положительного кеша, -способного пережить Android-сокет. Неизвестный владелец, ошибка API и устаревший сеанс -блокируются. В split-режиме разрешены только выбранные UID; в глобальном требуется -определимый владелец. Приложения с общим UID неразличимы. Запрос владельца и передача — -раздельные операции, а не атомарная гарантия ядра. Медленные проверки новых потоков идут -вне общего цикла TUN с ограничением 64 одновременных проверок; лишние попытки закрываются. -Проверки UDP увеличивают задержку и нагрузку и могут нарушать работу приложений и трафика -без определимого владельца, например раздачи интернета. Прямого обхода блокировки нет. +Оба уровня проверяют владельца TCP один раз до выхода через прокси или напрямую в LAN. +Это добавляет задержку при установлении соединения; повторных проверок на TCP-данных нет. +UDP, включая DNS, проверяется при создании потока. Для UDP доступны два уровня: + +| Уровень | Проверка владельца UDP | Компромисс | +| --- | --- | --- | +| Стандартный (первоначальный выбор) | При создании потока и на первой исходящей датаграмме после фиксированного интервала 1 секунду | Меньше запросов Android API; повторно использованный порт может временно наследовать прежнее разрешение | +| Расширенный | При создании потока и на каждой исходящей датаграмме | Быстрее обнаруживает смену владельца; больше нагрузка и задержка | + +Кеш стандартного уровня принадлежит только конкретному живому потоку: он не разделяется +по исходному порту, между адресатами или следующими потоками. Попадания в кеш не продлевают +срок; фоновый таймер не нужен. После истечения срока следующая датаграмма требует новой +проверки до передачи. При отказе поток закрывается. Между проверками сознательно допускается +окно устаревшего разрешения до 1 секунды; этот уровень не равнозначен расширенному. +Ни один уровень не делает запрос владельца и передачу атомарной операцией ядра. + +Неизвестный владелец и ошибка API при проверке блокируются. В split-режиме разрешены только +выбранные UID; в глобальном требуется определимый владелец. Приложения с общим UID +неразличимы. Медленные проверки новых потоков идут вне общего цикла TUN с ограничением +64 одновременных проверок; лишние попытки закрываются. Оба уровня могут нарушать работу +приложений и трафика без определимого владельца, например раздачи интернета. +Прямого обхода блокировки нет. + +Уровень тоже хранится локально и применяется при переподключении. Ранее включённая опция +без указанного уровня переносится в расширенный режим с сохранением поведения; +при первом включении выбирается стандартный. Видимость показывает фактически работающий +уровень, в том числе когда его смена ещё ожидает переподключения. Echo (ping) IPv4/IPv6 блокируется до userspace-стека; обычные ошибки ICMP обрабатываются штатно. Фрагментированный ICMP и IPv6-фрагменты с неопределимым транспортом также блокируются. diff --git a/native/mobile/mobile.go b/native/mobile/mobile.go index f6b68bd9..6b829929 100644 --- a/native/mobile/mobile.go +++ b/native/mobile/mobile.go @@ -36,10 +36,10 @@ var state struct { // Start borrows tunFD for this call. Android keeps its ParcelFileDescriptor open; // Go owns only the duplicate made here, including every failure path. func Start(tunFD int, mtu int, rawConfig string, protector Protector, reporter Reporter) error { - return start(tunFD, mtu, rawConfig, protector, reporter, nil) + return start(tunFD, mtu, rawConfig, protector, reporter, nil, 0) } -func start(tunFD int, mtu int, rawConfig string, protector Protector, reporter Reporter, validator SourceValidator) error { +func start(tunFD int, mtu int, rawConfig string, protector Protector, reporter Reporter, validator SourceValidator, udpRecheck time.Duration) error { tunFD, err := duplicateTunFD(tunFD) if err != nil { return err @@ -128,7 +128,7 @@ func start(tunFD int, mtu int, rawConfig string, protector Protector, reporter R t.SetProxy(sshProxy) proxyCloser = sshProxy } - netstack, err := core.CreateStack(sourceStackConfig(dev, dnsTransport{t}, validator)) + netstack, err := core.CreateStack(sourceStackConfig(dev, dnsTransport{t}, validator, udpRecheck)) if err != nil { dev.Close() if proxyCloser != nil { diff --git a/native/mobile/source_validation.go b/native/mobile/source_validation.go index 890c0702..cb294438 100644 --- a/native/mobile/source_validation.go +++ b/native/mobile/source_validation.go @@ -3,6 +3,8 @@ package mobile import ( "errors" "net" + "sync" + "time" "github.com/xjasonlyu/tun2socks/v2/core" "github.com/xjasonlyu/tun2socks/v2/core/adapter" @@ -16,17 +18,21 @@ type SourceValidator interface { } // StartStrict uses a separate entry point so normal Start keeps its existing data path. -func StartStrict(tunFD int, mtu int, rawConfig string, protector Protector, reporter Reporter, validator SourceValidator) error { +func StartStrict(tunFD int, mtu int, rawConfig string, protector Protector, reporter Reporter, validator SourceValidator, udpRecheckMillis int) error { if validator == nil { return errors.New("strict source validator is required") } - return start(tunFD, mtu, rawConfig, protector, reporter, validator) + if udpRecheckMillis != 0 && udpRecheckMillis != 1000 { + return errors.New("unsupported UDP source validation interval") + } + return start(tunFD, mtu, rawConfig, protector, reporter, validator, time.Duration(udpRecheckMillis)*time.Millisecond) } type sourceTransport struct { adapter.TransportHandler - validator SourceValidator - slots chan struct{} + validator SourceValidator + slots chan struct{} + udpRecheck time.Duration } func sourceAllowed(validator SourceValidator, protocol int, id stack.TransportEndpointID) bool { @@ -58,19 +64,51 @@ func (t sourceTransport) HandleUDP(conn adapter.UDPConn) { _ = conn.Close() return } - // No positive cache: a closed Android UDP socket's port can belong to a different UID. - t.TransportHandler.HandleUDP(&sourceUDP{UDPConn: conn, validator: t.validator}) + guarded := &sourceUDP{UDPConn: conn, validator: t.validator, recheckInterval: t.udpRecheck} + if t.udpRecheck > 0 { + guarded.recheckAt = time.Now().Add(t.udpRecheck) + } + t.TransportHandler.HandleUDP(guarded) }) } type sourceUDP struct { adapter.UDPConn - validator SourceValidator + validator SourceValidator + recheckInterval time.Duration + mu sync.Mutex + recheckAt time.Time + denied bool +} + +func (c *sourceUDP) allowed() bool { + if c.recheckInterval == 0 { + return sourceAllowed(c.validator, 17, c.ID()) + } + return c.allowedAt(time.Now()) +} + +func (c *sourceUDP) allowedAt(now time.Time) bool { + c.mu.Lock() + defer c.mu.Unlock() + if c.denied { + return false + } + if now.Before(c.recheckAt) { + return true + } + if !sourceAllowed(c.validator, 17, c.ID()) { + c.denied = true + return false + } + // ponytail: one-second permission window per UDP flow; use per-datagram mode for stricter checks. + c.recheckAt = now.Add(c.recheckInterval) + return true } func (c *sourceUDP) Read(p []byte) (int, error) { n, err := c.UDPConn.Read(p) - if (err == nil || n > 0) && !sourceAllowed(c.validator, 17, c.ID()) { + if (err == nil || n > 0) && !c.allowed() { _ = c.Close() return 0, errors.New("UDP source not authorized") } @@ -79,7 +117,7 @@ func (c *sourceUDP) Read(p []byte) (int, error) { func (c *sourceUDP) ReadFrom(p []byte) (int, net.Addr, error) { n, address, err := c.UDPConn.ReadFrom(p) - if (err == nil || n > 0) && !sourceAllowed(c.validator, 17, c.ID()) { + if (err == nil || n > 0) && !c.allowed() { _ = c.Close() return 0, nil, errors.New("UDP source not authorized") } @@ -87,10 +125,10 @@ func (c *sourceUDP) ReadFrom(p []byte) (int, net.Addr, error) { } // Selection happens only at startup. Disabled sessions retain both original objects. -func sourceStackConfig(endpoint stack.LinkEndpoint, handler adapter.TransportHandler, validator SourceValidator) *core.Config { +func sourceStackConfig(endpoint stack.LinkEndpoint, handler adapter.TransportHandler, validator SourceValidator, udpRecheck time.Duration) *core.Config { cfg := &core.Config{LinkEndpoint: endpoint, TransportHandler: handler} if validator != nil { - cfg.TransportHandler = sourceTransport{TransportHandler: handler, validator: validator, slots: make(chan struct{}, 64)} + cfg.TransportHandler = sourceTransport{TransportHandler: handler, validator: validator, slots: make(chan struct{}, 64), udpRecheck: udpRecheck} cfg.LinkEndpoint = &sourceLinkEndpoint{LinkEndpoint: endpoint} } return cfg diff --git a/native/mobile/source_validation_test.go b/native/mobile/source_validation_test.go index 3293905a..68e065b2 100644 --- a/native/mobile/source_validation_test.go +++ b/native/mobile/source_validation_test.go @@ -3,6 +3,7 @@ package mobile import ( "net" "testing" + "time" "github.com/xjasonlyu/tun2socks/v2/core/adapter" "gvisor.dev/gvisor/pkg/tcpip" @@ -72,7 +73,7 @@ func TestSourceValidationAdmissionAndUDPPortReuse(t *testing.T) { v := &sourceTestValidator{} events := make(chan struct{}, 8) handler := &sourceTestTransport{events: events} - guarded := sourceTransport{handler, v, make(chan struct{}, 64)} + guarded := sourceTransport{handler, v, make(chan struct{}, 64), 0} tcp := &sourceTestConn{events: events} guarded.HandleTCP(tcp) <-events @@ -171,11 +172,11 @@ func TestSourceEchoPolicy(t *testing.T) { func TestDisabledSourceValidationKeepsOriginalDataPath(t *testing.T) { endpoint := &sourceLinkEndpoint{} handler := &sourceTestTransport{} - normal := sourceStackConfig(endpoint, handler, nil) + normal := sourceStackConfig(endpoint, handler, nil, 0) if normal.LinkEndpoint != endpoint || normal.TransportHandler != handler { t.Fatal("disabled mode installed a data-path wrapper") } - strict := sourceStackConfig(endpoint, handler, &sourceTestValidator{}) + strict := sourceStackConfig(endpoint, handler, &sourceTestValidator{}, 0) if strict.LinkEndpoint == endpoint || strict.TransportHandler == handler { t.Fatal("strict mode omitted protection") } @@ -192,7 +193,7 @@ func TestSourceLookupDoesNotBlockTUNAndAdmissionIsBounded(t *testing.T) { events := make(chan struct{}, 2) v := sourceBlockingValidator{make(chan struct{}), make(chan struct{})} handler := &sourceTestTransport{events: events} - transport := sourceTransport{handler, v, make(chan struct{}, 1)} + transport := sourceTransport{handler, v, make(chan struct{}, 1), 0} transport.HandleTCP(&sourceTestConn{}) // Must return while the Android query is blocked. <-v.entered second := &sourceTestConn{events: events} @@ -204,3 +205,42 @@ func TestSourceLookupDoesNotBlockTUNAndAdmissionIsBounded(t *testing.T) { close(v.release) <-events } + +func TestStandardUDPValidationWindowIsFixedAndFlowLocal(t *testing.T) { + v := &sourceTestValidator{allowed: true} + now := time.Unix(100, 0) + conn := &sourceUDP{UDPConn: &sourceTestConn{}, validator: v, recheckInterval: time.Second} + if !conn.allowedAt(now) || v.calls != 1 { + t.Fatal("new flow was not checked") + } + v.allowed = false + if !conn.allowedAt(now.Add(900*time.Millisecond)) || v.calls != 1 { + t.Fatal("standard mode did not cache admission") + } + if !conn.allowedAt(now.Add(999 * time.Millisecond)) { + t.Fatal("cache expired early") + } + if conn.allowedAt(now.Add(time.Second)) || v.calls != 2 { + t.Fatal("cache hit extended the stale-owner window") + } + another := &sourceUDP{UDPConn: &sourceTestConn{}, validator: v, recheckInterval: time.Second} + if another.allowedAt(now) || v.calls != 3 { + t.Fatal("new flow reused another flow's decision") + } + v.allowed = true + if conn.allowedAt(now.Add(2*time.Second)) || v.calls != 3 { + t.Fatal("denied flow resurrected cached permission") + } + fresh := &sourceUDP{UDPConn: &sourceTestConn{}, validator: v, recheckInterval: time.Second} + if !fresh.allowedAt(now.Add(2*time.Second)) || v.calls != 4 { + t.Fatal("fresh socket not rechecked") + } +} + +func TestSourceValidationIntervalRejectsUnsupportedModes(t *testing.T) { + for _, interval := range []int{-1, 1, 1001, 1 << 30} { + if err := StartStrict(-1, 1400, "", nil, nil, &sourceTestValidator{}, interval); err == nil || err.Error() != "unsupported UDP source validation interval" { + t.Fatal("unbounded source cache interval") + } + } +} From 88d8d8efe6b8038e2748693e09b618558315a846 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 27 Oct 2026 16:53:18 +0300 Subject: [PATCH 4/4] Bound source validation work and promptly release denied UDP flows --- AGENTS.md | 2 + .../java/net/megaproxy487/vpn/ProxyCore.kt | 6 +- docs/en/source-validation.md | 8 +- docs/ru/source-validation.md | 9 +- native/mobile/robustness_test.go | 11 ++ native/mobile/source_icmp.go | 68 ++++--- native/mobile/source_relay.go | 59 ++++++ native/mobile/source_relay_test.go | 152 +++++++++++++++ native/mobile/source_validation.go | 53 ++++-- native/mobile/source_validation_test.go | 174 +++++++++++++++++- 10 files changed, 496 insertions(+), 46 deletions(-) create mode 100644 native/mobile/source_relay.go create mode 100644 native/mobile/source_relay_test.go diff --git a/AGENTS.md b/AGENTS.md index ab4d78d3..a9fafbd1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -174,6 +174,8 @@ branch names, credentials, signing material, or other secrets. Advanced checks every outgoing datagram. New opt-in defaults to Standard; legacy enabled settings migrate to Advanced. Keep both levels local and preserve them across imports. Visibility reports the actual running level and pending changes, not proof of a separate-app bypass test. + Bound all ownership JNI queries across sessions (including UDP rechecks), avoid ordinary payload copies and repeated prefix coalescing + in the ICMP filter, and prepare numeric UDP endpoint strings once per flow. ## Architecture landmarks diff --git a/app/src/main/java/net/megaproxy487/vpn/ProxyCore.kt b/app/src/main/java/net/megaproxy487/vpn/ProxyCore.kt index 81472426..4d38365c 100644 --- a/app/src/main/java/net/megaproxy487/vpn/ProxyCore.kt +++ b/app/src/main/java/net/megaproxy487/vpn/ProxyCore.kt @@ -146,9 +146,9 @@ class NativeProxyCore( ParcelFileDescriptor.fromFd(tunFd).use { borrowed -> if (config.strictSourceValidation) { if (android.os.Build.VERSION.SDK_INT < 29) throw IllegalStateException(vpnService.getString(net.megaproxy487.R.string.strict_source_unsupported)) - val uids = if (config.routeAllApps) null else config.selectedPackages.mapNotNull { name -> - runCatching { vpnService.packageManager.getApplicationInfo(name, 0).uid }.getOrNull() - }.toSet().ifEmpty { setOf(vpnService.applicationInfo.uid) } + val uids = if (config.routeAllApps) null else config.selectedPackages.mapNotNullTo(mutableSetOf()) { name -> + operationResult { vpnService.packageManager.getApplicationInfo(name, 0).uid }.getOrNull() + }.ifEmpty { setOf(vpnService.applicationInfo.uid) } val connectivity = vpnService.getSystemService(android.net.ConnectivityManager::class.java) val validator = BridgeSourceValidator( { callbacksEnabled.get() && isCurrent() }, diff --git a/docs/en/source-validation.md b/docs/en/source-validation.md index cce1f3dd..e12db301 100644 --- a/docs/en/source-validation.md +++ b/docs/en/source-validation.md @@ -26,7 +26,13 @@ lookup and forwarding an atomic kernel operation. Unknown owners and lookup exceptions are denied when checked. Split mode accepts only the selected application UIDs; global mode requires an identifiable owner. Packages sharing a UID cannot be distinguished. Slow new-flow lookups run outside the shared TUN loop with at most -64 concurrent admissions; excess attempts are closed. Both levels may disrupt apps or traffic +64 concurrent admissions. At most 8 ownership lookups can enter Android/JNI concurrently, +shared across active and retiring sessions; excess requests fail closed. The query budget +is initialized only when protection is enabled. Numeric UDP addresses are prepared once +per flow. The ICMP filter reads only short headers for ordinary packets; unusually long +extension chains use one packet copy rather than repeatedly coalescing growing prefixes. +Strict UDP relays close both TUN and upstream when either direction fails, so a denied +source cannot retain an idle proxy association until the normal UDP timeout. Both levels may disrupt apps or traffic without a visible owner, such as tethering. No direct retry bypasses a denial. The selected level is also device-local and applies on reconnection. Existing enabled settings diff --git a/docs/ru/source-validation.md b/docs/ru/source-validation.md index e9ad294d..a51ccfcb 100644 --- a/docs/ru/source-validation.md +++ b/docs/ru/source-validation.md @@ -26,7 +26,14 @@ UDP, включая DNS, проверяется при создании пото Неизвестный владелец и ошибка API при проверке блокируются. В split-режиме разрешены только выбранные UID; в глобальном требуется определимый владелец. Приложения с общим UID неразличимы. Медленные проверки новых потоков идут вне общего цикла TUN с ограничением -64 одновременных проверок; лишние попытки закрываются. Оба уровня могут нарушать работу +64 одновременных допусков. Не более 8 запросов владельца одновременно входят в Android/JNI; +лимит общий для активных и завершающихся сеансов. Лишние запросы блокируются. Бюджет +запросов создаётся только при включении защиты. Числовые адреса UDP готовятся один раз +для потока. Для обычных пакетов фильтр ICMP читает только короткие заголовки; необычно +длинная цепочка расширений использует одну копию пакета вместо повторного объединения +растущих префиксов. +При ошибке любого направления строгий UDP-relay закрывает TUN и upstream, чтобы отказ +владельцу не удерживал простаивающую ассоциацию с прокси до обычного UDP-таймаута. Оба уровня могут нарушать работу приложений и трафика без определимого владельца, например раздачи интернета. Прямого обхода блокировки нет. diff --git a/native/mobile/robustness_test.go b/native/mobile/robustness_test.go index a6138e64..9ddc3fb2 100644 --- a/native/mobile/robustness_test.go +++ b/native/mobile/robustness_test.go @@ -134,6 +134,16 @@ func TestClosedPacketDeadlineCannotRetainNewTimers(t *testing.T) { } func FuzzNativeParsers(f *testing.F) { + ipv4Echo := make([]byte, 28) + ipv4Echo[0] = 0x45 + ipv4Echo[9] = 1 + ipv4Echo[20] = 8 + ipv6Echo := make([]byte, 48) + ipv6Echo[0] = 0x60 + ipv6Echo[6] = 58 + ipv6Echo[40] = 128 + f.Add(ipv4Echo) + f.Add(ipv6Echo) f.Add([]byte(validNativeConfig)) f.Add([]byte("771,4865,0,29,0")) f.Add([]byte{0, 1, 128, 0, 0, 1, 0, 0, 0, 0, 0, 0}) @@ -141,6 +151,7 @@ func FuzzNativeParsers(f *testing.F) { if len(data) > 1024*1024 { t.Skip() } + _ = blockSourceEcho(data) _, _ = parseConfig(string(data)) _, _ = parseJA3(string(data)) _, _, _ = buildAQuery(string(data)) diff --git a/native/mobile/source_icmp.go b/native/mobile/source_icmp.go index b539156f..1a63489c 100644 --- a/native/mobile/source_icmp.go +++ b/native/mobile/source_icmp.go @@ -2,6 +2,7 @@ package mobile import ( "encoding/binary" + "gvisor.dev/gvisor/pkg/buffer" "gvisor.dev/gvisor/pkg/tcpip" "gvisor.dev/gvisor/pkg/tcpip/stack" @@ -21,77 +22,92 @@ func (e *sourceLinkEndpoint) Attach(dispatcher stack.NetworkDispatcher) { type sourceDispatcher struct{ stack.NetworkDispatcher } func (d sourceDispatcher) DeliverNetworkPacket(protocol tcpip.NetworkProtocolNumber, pkt *stack.PacketBuffer) { - // ponytail: strict-only packet copy, bounded by TUN MTU; use header views if profiling warrants. - view := pkt.ToView() - blocked := blockSourceEcho(view.AsSlice()) - view.Release() + data := pkt.Data() + var copied *buffer.View + blocked := blockSourceEchoPrefix(func(n int) ([]byte, bool) { + if copied == nil && n <= 128 { + return data.PullUp(n) + } + // ponytail: long extension chains use one MTU-sized copy, avoiding repeated prefix coalescing. + if copied == nil { + copied = pkt.ToView() + } + p := copied.AsSlice() + if n > len(p) { + return nil, false + } + return p[:n], true + }) + if copied != nil { + copied.Release() + } if !blocked { d.NetworkDispatcher.DeliverNetworkPacket(protocol, pkt) } } // Preserve ICMP errors (including Packet Too Big); block Echo, even behind IPv6 extensions. -func blockSourceEcho(p []byte) bool { - if len(p) == 0 { +func blockSourceEchoPrefix(prefix func(int) ([]byte, bool)) bool { + p, ok := prefix(1) + if !ok { return true } switch p[0] >> 4 { case 4: - if len(p) < 20 { + p, ok = prefix(20) + if !ok { return true } if p[9] != 1 { return false } offset := int(p[0]&15) * 4 - if offset < 20 || len(p) <= offset { + if offset < 20 { return true } - // Fragmented ICMP cannot be attributed safely without a separate reassembly cache. if binary.BigEndian.Uint16(p[6:8])&0x3fff != 0 { return true } - return p[offset] == 0 || p[offset] == 8 + p, ok = prefix(offset + 1) + return !ok || p[offset] == 0 || p[offset] == 8 case 6: - if len(p) < 40 { + p, ok = prefix(40) + if !ok { return true } next, offset := p[6], 40 for steps := 0; steps < 16; steps++ { switch next { case 58: - return len(p) <= offset || p[offset] == 128 || p[offset] == 129 - case 0, 43, 60: // Hop-by-hop, routing, destination options. - if len(p) < offset+2 { + p, ok = prefix(offset + 1) + return !ok || p[offset] == 128 || p[offset] == 129 + case 0, 43, 60, 51: + p, ok = prefix(offset + 2) + if !ok { return true } length := (int(p[offset+1]) + 1) * 8 - if len(p) < offset+length { + if next == 51 { + length = (int(p[offset+1]) + 2) * 4 + } + p, ok = prefix(offset + length) + if !ok { return true } next, offset = p[offset], offset+length case 44: - if len(p) < offset+8 { + p, ok = prefix(offset + 8) + if !ok { return true } fragmentNext := p[offset] if binary.BigEndian.Uint16(p[offset+2:offset+4])&0xfff8 != 0 { - // Non-initial TCP/UDP fragments still undergo validation after reassembly. return fragmentNext != 6 && fragmentNext != 17 } if fragmentNext == 58 { return true } next, offset = fragmentNext, offset+8 - case 51: // Authentication header. - if len(p) < offset+2 { - return true - } - length := (int(p[offset+1]) + 2) * 4 - if len(p) < offset+length { - return true - } - next, offset = p[offset], offset+length default: return false } diff --git a/native/mobile/source_relay.go b/native/mobile/source_relay.go new file mode 100644 index 00000000..382aadbb --- /dev/null +++ b/native/mobile/source_relay.go @@ -0,0 +1,59 @@ +package mobile + +import ( + "net" + "net/netip" + "sync" + + "github.com/xjasonlyu/tun2socks/v2/core/adapter" + M "github.com/xjasonlyu/tun2socks/v2/metadata" + "github.com/xjasonlyu/tun2socks/v2/tunnel/statistic" +) + +// Only strict sessions use this handler. Stock UDP forwarding waits for both +// copy loops before closing upstream; a source denial must close it immediately. +type sourceUDPTransport struct{ dnsTransport } + +func (t sourceUDPTransport) HandleUDP(origin adapter.UDPConn) { + id := origin.ID() + if id.LocalPort == 53 { + t.dnsTransport.HandleUDP(origin) + return + } + source, _ := netip.AddrFromSlice(id.RemoteAddress.AsSlice()) + target, _ := netip.AddrFromSlice(id.LocalAddress.AsSlice()) + if !source.IsValid() || !target.IsValid() { + _ = origin.Close() + return + } + metadata := &M.Metadata{Network: M.UDP, SrcIP: source, SrcPort: id.RemotePort, DstIP: target, DstPort: id.LocalPort} + packet, err := t.Proxy().DialUDP(metadata) + if err != nil { + _ = origin.Close() + return + } + peer := metadata.UDPAddr() + remote := &sourcePeerPackets{PacketConn: statistic.NewUDPTracker(packet, metadata, statistic.DefaultManager), peer: peer.String()} + go func() { + var once sync.Once + shutdown := func() { once.Do(func() { _ = origin.Close(); _ = remote.Close() }) } + defer shutdown() + go func() { defer shutdown(); relayDNSPackets(remote, origin, peer, nil) }() + relayDNSPackets(origin, remote, nil, nil) + }() +} + +// Keep the existing symmetric-NAT policy in addition to the dialers' peer checks. +type sourcePeerPackets struct { + net.PacketConn + peer string +} + +func (p *sourcePeerPackets) ReadFrom(b []byte) (int, net.Addr, error) { + for { + n, from, err := p.PacketConn.ReadFrom(b) + if err != nil || from == nil || from.String() == p.peer { + return n, from, err + } + } +} diff --git a/native/mobile/source_relay_test.go b/native/mobile/source_relay_test.go new file mode 100644 index 00000000..28ed7d8f --- /dev/null +++ b/native/mobile/source_relay_test.go @@ -0,0 +1,152 @@ +package mobile + +import ( + "io" + "net" + "sync" + "sync/atomic" + "testing" + "time" + + M "github.com/xjasonlyu/tun2socks/v2/metadata" + "github.com/xjasonlyu/tun2socks/v2/proxy" + "github.com/xjasonlyu/tun2socks/v2/tunnel" + "github.com/xjasonlyu/tun2socks/v2/tunnel/statistic" + "gvisor.dev/gvisor/pkg/tcpip/stack" +) + +type sourceRelayOrigin struct{ net.Conn } + +func (c sourceRelayOrigin) ID() stack.TransportEndpointID { + id := (&sourceTestConn{}).ID() + id.LocalPort = 443 + return id +} +func (c sourceRelayOrigin) ReadFrom(b []byte) (int, net.Addr, error) { + n, e := c.Read(b) + return n, nil, e +} +func (c sourceRelayOrigin) WriteTo(b []byte, _ net.Addr) (int, error) { return c.Write(b) } + +type sourceRelayProxy struct { + proxy.Proxy + packet net.PacketConn +} + +func (p sourceRelayProxy) DialUDP(*M.Metadata) (net.PacketConn, error) { return p.packet, nil } + +type sourceRelayReply struct { + data []byte + peer net.Addr +} + +type sourceRelayPacket struct { + closed chan struct{} + written chan struct{} + incoming chan sourceRelayReply + once sync.Once +} + +func (p *sourceRelayPacket) ReadFrom(b []byte) (int, net.Addr, error) { + select { + case <-p.closed: + return 0, nil, net.ErrClosed + case reply := <-p.incoming: + return copy(b, reply.data), reply.peer, nil + } +} +func (p *sourceRelayPacket) WriteTo(b []byte, _ net.Addr) (int, error) { + p.written <- struct{}{} + return len(b), nil +} +func (p *sourceRelayPacket) Close() error { p.once.Do(func() { close(p.closed) }); return nil } +func (p *sourceRelayPacket) LocalAddr() net.Addr { + return &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 1} +} +func (p *sourceRelayPacket) SetDeadline(time.Time) error { return nil } +func (p *sourceRelayPacket) SetReadDeadline(time.Time) error { return nil } +func (p *sourceRelayPacket) SetWriteDeadline(time.Time) error { return nil } + +func TestSourceDenialAndOriginCloseReleaseIdleUpstreamImmediately(t *testing.T) { + for _, deny := range []bool{false, true} { + t.Run(map[bool]string{false: "origin-close", true: "ownership-denied"}[deny], func(t *testing.T) { + upstream := &sourceRelayPacket{closed: make(chan struct{}), written: make(chan struct{}, 1)} + defer upstream.Close() + origin, peer := net.Pipe() + defer peer.Close() + var allowed atomic.Bool + allowed.Store(true) + guarded := newSourceUDP(sourceRelayOrigin{origin}, sourceValidatorFunc(func(int, string, int, string, int) bool { return allowed.Load() }), 0) + transport := sourceUDPTransport{dnsTransport{tunnel.New(sourceRelayProxy{packet: upstream}, statistic.DefaultManager)}} + transport.HandleUDP(guarded) + if _, err := peer.Write([]byte("first")); err != nil { + t.Fatal(err) + } + select { + case <-upstream.written: + case <-time.After(time.Second): + t.Fatal("first datagram was not relayed") + } + if deny { + allowed.Store(false) + _, _ = peer.Write([]byte("denied")) + } else { + peer.Close() + } + select { + case <-upstream.closed: + case <-time.After(time.Second): + t.Fatal("idle upstream retained after source stopped") + } + select { + case <-upstream.written: + t.Fatal("denied datagram escaped") + default: + } + if deny { + _ = peer.SetReadDeadline(time.Now().Add(time.Second)) + if _, err := peer.Read(make([]byte, 1)); err != io.EOF { + t.Fatal("origin remained open", err) + } + } + }) + } +} + +func TestSourceRelayPreservesPeerFilteringAndReplies(t *testing.T) { + upstream := &sourceRelayPacket{closed: make(chan struct{}), written: make(chan struct{}, 1), incoming: make(chan sourceRelayReply, 2)} + defer upstream.Close() + origin, peer := net.Pipe() + defer peer.Close() + guarded := newSourceUDP(sourceRelayOrigin{origin}, sourceValidatorFunc(func(int, string, int, string, int) bool { return true }), 0) + transport := sourceUDPTransport{dnsTransport{tunnel.New(sourceRelayProxy{packet: upstream}, statistic.DefaultManager)}} + transport.HandleUDP(guarded) + if _, err := peer.Write([]byte("request")); err != nil { + t.Fatal(err) + } + <-upstream.written + upstream.incoming <- sourceRelayReply{[]byte("wrong-peer"), &net.UDPAddr{IP: net.IPv4(192, 0, 2, 2), Port: 443}} + upstream.incoming <- sourceRelayReply{[]byte("response"), &net.UDPAddr{IP: net.IPv4(192, 0, 2, 1), Port: 443}} + _ = peer.SetReadDeadline(time.Now().Add(time.Second)) + b := make([]byte, 32) + if n, err := peer.Read(b); err != nil || string(b[:n]) != "response" { + t.Fatal("reply peer validation changed", n, err) + } + peer.Close() + select { + case <-upstream.closed: + case <-time.After(time.Second): + t.Fatal("upstream retained after round trip") + } +} + +type sourceInvalidOrigin struct{ *sourceTestConn } + +func (sourceInvalidOrigin) ID() stack.TransportEndpointID { return stack.TransportEndpointID{} } +func TestSourceRelayRejectsInvalidAddressesBeforeDial(t *testing.T) { + origin := &sourceTestConn{} + sourceUDPTransport{}.HandleUDP(sourceInvalidOrigin{origin}) + if !origin.closed { + t.Fatal("invalid source tuple was not closed") + } +} diff --git a/native/mobile/source_validation.go b/native/mobile/source_validation.go index cb294438..b98e8bb7 100644 --- a/native/mobile/source_validation.go +++ b/native/mobile/source_validation.go @@ -28,6 +28,25 @@ func StartStrict(tunFD int, mtu int, rawConfig string, protector Protector, repo return start(tunFD, mtu, rawConfig, protector, reporter, validator, time.Duration(udpRecheckMillis)*time.Millisecond) } +// Shared across sessions: repeated reconnects must not accumulate attached JNI threads. +// The semaphore is allocated only when strict validation is first enabled. +var sourceQuerySlots = sync.OnceValue(func() chan struct{} { return make(chan struct{}, 8) }) + +type limitedSourceValidator struct { + SourceValidator + slots chan struct{} +} + +func (v limitedSourceValidator) Allow(protocol int, source string, sourcePort int, destination string, destinationPort int) bool { + select { + case v.slots <- struct{}{}: + defer func() { <-v.slots }() + return v.SourceValidator.Allow(protocol, source, sourcePort, destination, destinationPort) + default: + return false + } +} + type sourceTransport struct { adapter.TransportHandler validator SourceValidator @@ -60,11 +79,11 @@ func (t sourceTransport) HandleTCP(conn adapter.TCPConn) { func (t sourceTransport) HandleUDP(conn adapter.UDPConn) { t.dispatch(conn, func() { - if !sourceAllowed(t.validator, 17, conn.ID()) { + guarded := newSourceUDP(conn, t.validator, t.udpRecheck) + if !guarded.query() { _ = conn.Close() return } - guarded := &sourceUDP{UDPConn: conn, validator: t.validator, recheckInterval: t.udpRecheck} if t.udpRecheck > 0 { guarded.recheckAt = time.Now().Add(t.udpRecheck) } @@ -74,16 +93,28 @@ func (t sourceTransport) HandleUDP(conn adapter.UDPConn) { type sourceUDP struct { adapter.UDPConn - validator SourceValidator - recheckInterval time.Duration - mu sync.Mutex - recheckAt time.Time - denied bool + source, destination string + sourcePort, destinationPort int + validator SourceValidator + recheckInterval time.Duration + mu sync.Mutex + recheckAt time.Time + denied bool +} + +func newSourceUDP(conn adapter.UDPConn, validator SourceValidator, interval time.Duration) *sourceUDP { + id := conn.ID() + return &sourceUDP{UDPConn: conn, validator: validator, recheckInterval: interval, + source: net.IP(id.RemoteAddress.AsSlice()).String(), sourcePort: int(id.RemotePort), + destination: net.IP(id.LocalAddress.AsSlice()).String(), destinationPort: int(id.LocalPort)} +} +func (c *sourceUDP) query() bool { + return c.validator.Allow(17, c.source, c.sourcePort, c.destination, c.destinationPort) } func (c *sourceUDP) allowed() bool { if c.recheckInterval == 0 { - return sourceAllowed(c.validator, 17, c.ID()) + return c.query() } return c.allowedAt(time.Now()) } @@ -97,7 +128,7 @@ func (c *sourceUDP) allowedAt(now time.Time) bool { if now.Before(c.recheckAt) { return true } - if !sourceAllowed(c.validator, 17, c.ID()) { + if !c.query() { c.denied = true return false } @@ -125,10 +156,10 @@ func (c *sourceUDP) ReadFrom(p []byte) (int, net.Addr, error) { } // Selection happens only at startup. Disabled sessions retain both original objects. -func sourceStackConfig(endpoint stack.LinkEndpoint, handler adapter.TransportHandler, validator SourceValidator, udpRecheck time.Duration) *core.Config { +func sourceStackConfig(endpoint stack.LinkEndpoint, handler dnsTransport, validator SourceValidator, udpRecheck time.Duration) *core.Config { cfg := &core.Config{LinkEndpoint: endpoint, TransportHandler: handler} if validator != nil { - cfg.TransportHandler = sourceTransport{TransportHandler: handler, validator: validator, slots: make(chan struct{}, 64), udpRecheck: udpRecheck} + cfg.TransportHandler = sourceTransport{TransportHandler: sourceUDPTransport{handler}, validator: limitedSourceValidator{validator, sourceQuerySlots()}, slots: make(chan struct{}, 64), udpRecheck: udpRecheck} cfg.LinkEndpoint = &sourceLinkEndpoint{LinkEndpoint: endpoint} } return cfg diff --git a/native/mobile/source_validation_test.go b/native/mobile/source_validation_test.go index 68e065b2..14fb0f87 100644 --- a/native/mobile/source_validation_test.go +++ b/native/mobile/source_validation_test.go @@ -1,7 +1,10 @@ package mobile import ( + "fmt" + "gvisor.dev/gvisor/pkg/buffer" "net" + "sync/atomic" "testing" "time" @@ -171,7 +174,7 @@ func TestSourceEchoPolicy(t *testing.T) { func TestDisabledSourceValidationKeepsOriginalDataPath(t *testing.T) { endpoint := &sourceLinkEndpoint{} - handler := &sourceTestTransport{} + handler := dnsTransport{} normal := sourceStackConfig(endpoint, handler, nil, 0) if normal.LinkEndpoint != endpoint || normal.TransportHandler != handler { t.Fatal("disabled mode installed a data-path wrapper") @@ -209,7 +212,7 @@ func TestSourceLookupDoesNotBlockTUNAndAdmissionIsBounded(t *testing.T) { func TestStandardUDPValidationWindowIsFixedAndFlowLocal(t *testing.T) { v := &sourceTestValidator{allowed: true} now := time.Unix(100, 0) - conn := &sourceUDP{UDPConn: &sourceTestConn{}, validator: v, recheckInterval: time.Second} + conn := newSourceUDP(&sourceTestConn{}, v, time.Second) if !conn.allowedAt(now) || v.calls != 1 { t.Fatal("new flow was not checked") } @@ -223,7 +226,7 @@ func TestStandardUDPValidationWindowIsFixedAndFlowLocal(t *testing.T) { if conn.allowedAt(now.Add(time.Second)) || v.calls != 2 { t.Fatal("cache hit extended the stale-owner window") } - another := &sourceUDP{UDPConn: &sourceTestConn{}, validator: v, recheckInterval: time.Second} + another := newSourceUDP(&sourceTestConn{}, v, time.Second) if another.allowedAt(now) || v.calls != 3 { t.Fatal("new flow reused another flow's decision") } @@ -231,7 +234,7 @@ func TestStandardUDPValidationWindowIsFixedAndFlowLocal(t *testing.T) { if conn.allowedAt(now.Add(2*time.Second)) || v.calls != 3 { t.Fatal("denied flow resurrected cached permission") } - fresh := &sourceUDP{UDPConn: &sourceTestConn{}, validator: v, recheckInterval: time.Second} + fresh := newSourceUDP(&sourceTestConn{}, v, time.Second) if !fresh.allowedAt(now.Add(2*time.Second)) || v.calls != 4 { t.Fatal("fresh socket not rechecked") } @@ -244,3 +247,166 @@ func TestSourceValidationIntervalRejectsUnsupportedModes(t *testing.T) { } } } + +func blockSourceEcho(p []byte) bool { + return blockSourceEchoPrefix(func(n int) ([]byte, bool) { + if n > len(p) { + return nil, false + } + return p[:n], true + }) +} + +type sourceCountingDispatcher struct{ packets int } + +func (d *sourceCountingDispatcher) DeliverNetworkPacket(tcpip.NetworkProtocolNumber, *stack.PacketBuffer) { + d.packets++ +} +func (d *sourceCountingDispatcher) DeliverLinkPacket(tcpip.NetworkProtocolNumber, *stack.PacketBuffer) { +} + +func TestSourceFilterReadsHeadersOnlyAndHandlesTruncation(t *testing.T) { + for _, v6 := range []bool{false, true} { + p := make([]byte, 65535) + headerSize := 20 + if v6 { + p[0] = 0x60 + p[6] = 17 + headerSize = 40 + } else { + p[0] = 0x45 + p[9] = 6 + } + requested := 0 + blocked := blockSourceEchoPrefix(func(n int) ([]byte, bool) { + if n > requested { + requested = n + } + return p[:n], true + }) + if blocked || requested != headerSize { + t.Fatal("payload was read/copied for a TCP/UDP packet") + } + } + // Every truncation of a non-empty extension chain must be safe to inspect. + p := make([]byte, 56) + p[0] = 0x60 + p[6] = 60 + p[40] = 58 + p[48] = 128 + for n := 0; n <= len(p); n++ { + _ = blockSourceEcho(p[:n]) + pkt := stack.NewPacketBuffer(stack.PacketBufferOptions{Payload: buffer.MakeWithData(p[:n])}) + receiver := &sourceCountingDispatcher{} + sourceDispatcher{receiver}.DeliverNetworkPacket(0x86dd, pkt) + pkt.DecRef() + if receiver.packets != 0 { + t.Fatalf("truncated/Echo packet escaped: %d bytes", n) + } + } +} + +func BenchmarkSourcePacketFilter(b *testing.B) { + for _, size := range []int{1500, 65535} { + p := make([]byte, size) + p[0] = 0x45 + p[9] = 6 + pkt := stack.NewPacketBuffer(stack.PacketBufferOptions{Payload: buffer.MakeWithData(p)}) + defer pkt.DecRef() + for _, mode := range []string{"disabled", "header-only", "previous-full-copy"} { + b.Run(fmt.Sprintf("%d/%s", size, mode), func(b *testing.B) { + receiver := &sourceCountingDispatcher{} + strict := sourceDispatcher{receiver} + b.ReportAllocs() + for b.Loop() { + switch mode { + case "disabled": + receiver.DeliverNetworkPacket(0x800, pkt) + case "header-only": + strict.DeliverNetworkPacket(0x800, pkt) + case "previous-full-copy": + v := pkt.ToView() + blocked := blockSourceEcho(v.AsSlice()) + v.Release() + if !blocked { + receiver.DeliverNetworkPacket(0x800, pkt) + } + } + } + }) + } + } +} + +type sourceValidatorFunc func(int, string, int, string, int) bool + +func (f sourceValidatorFunc) Allow(p int, s string, sp int, d string, dp int) bool { + return f(p, s, sp, d, dp) +} +func TestSourceQueriesAreBoundedAcrossUDPAndAdmissions(t *testing.T) { + entered := make(chan struct{}, 2) + release := make(chan struct{}) + var calls atomic.Int32 + validator := limitedSourceValidator{sourceValidatorFunc(func(int, string, int, string, int) bool { calls.Add(1); entered <- struct{}{}; <-release; return true }), make(chan struct{}, 2)} + done := make(chan struct{}, 2) + for i := 0; i < 2; i++ { + go func() { validator.Allow(17, "", 1, "", 2); done <- struct{}{} }() + } + <-entered + <-entered + if validator.Allow(6, "", 1, "", 2) || calls.Load() != 2 { + t.Fatal("excess query attached another JNI thread") + } + close(release) + <-done + <-done + if !validator.Allow(17, "", 1, "", 2) { + t.Fatal("query slot leaked") + } +} + +func TestSourceUDPReusesNumericAddressesWithoutGoAllocations(t *testing.T) { + v := &sourceTestValidator{allowed: true} + conn := newSourceUDP(&sourceTestConn{}, v, 0) + allocations := testing.AllocsPerRun(1000, func() { + if !conn.query() { + panic("unexpected denial") + } + }) + if allocations != 0 { + t.Fatalf("UDP reformats addresses on every query: %v allocations", allocations) + } +} + +func TestSourceQueryBudgetSurvivesSessionReplacement(t *testing.T) { + endpoint := &sourceLinkEndpoint{} + handler := dnsTransport{} + first := sourceStackConfig(endpoint, handler, &sourceTestValidator{}, 0).TransportHandler.(sourceTransport).validator.(limitedSourceValidator) + second := sourceStackConfig(endpoint, handler, &sourceTestValidator{}, time.Second).TransportHandler.(sourceTransport).validator.(limitedSourceValidator) + if first.slots != second.slots || cap(first.slots) != 8 { + t.Fatal("reconnect reset the JNI concurrency budget") + } +} + +func TestSourceFilterHandlesSegmentedLongExtensionChains(t *testing.T) { + p := make([]byte, 40+8*128+8) + p[0] = 0x60 + p[6] = 60 + for offset := 40; offset < 40+8*128; offset += 128 { + p[offset] = 60 + p[offset+1] = 15 + } + p[40+7*128] = 17 + payload := buffer.MakeWithData(p[:64]) + for offset := 64; offset < len(p); offset += 64 { + part := buffer.MakeWithData(p[offset:min(offset+64, len(p))]) + payload.Merge(&part) + } + pkt := stack.NewPacketBuffer(stack.PacketBufferOptions{Payload: payload}) + defer pkt.DecRef() + receiver := &sourceCountingDispatcher{} + sourceDispatcher{receiver}.DeliverNetworkPacket(0x86dd, pkt) + if receiver.packets != 1 || pkt.Data().Size() != len(p) { + t.Fatal("long headers changed or blocked a UDP packet") + } +}