diff --git a/AGENTS.md b/AGENTS.md index 3656474..a9fafbd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -167,6 +167,16 @@ 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 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. + 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 - Validate networking optimizations against latency measurements. Prefer bounded racing of alternatives diff --git a/README.md b/README.md index 200ba4c..1731af6 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/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/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt b/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt index e2c0b8c..870c03a 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 @@ -27,7 +28,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) + SourceValidationLevel.entries.forEach { socks5Traffic(true, strict = true, level = it) } + } + + 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( @@ -37,12 +43,14 @@ 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, 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 } @@ -51,6 +59,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 0000000..f5c6151 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/SourceProtection.kt @@ -0,0 +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: SourceValidationLevel?, running: Boolean, active: SourceValidationLevel?): SourceProtectionStatus = when { + sdk < 29 -> SourceProtectionStatus.UNSUPPORTED + !running -> SourceProtectionStatus.STOPPED + configured != active -> SourceProtectionStatus.RECONNECT + 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 dea14d6..704a03a 100644 --- a/app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt +++ b/app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt @@ -210,6 +210,46 @@ 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.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 97cbb2e..f026026 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.sourceValidationLevel.takeIf { 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: 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 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 == 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 2baf733..ee39459 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigEdits.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigEdits.kt @@ -89,6 +89,8 @@ 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) } 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 a3647ee..fca0b98 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigStore.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigStore.kt @@ -770,11 +770,13 @@ class ConfigStore(context: Context) { put("routeAllApps", settings.routeAllApps) 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 { 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 +793,12 @@ class ConfigStore(context: Context) { val parsedTls = enumValue(item.optString("fingerprint"), TlsProfile.DEFAULT) 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 935fb58..b7f5de6 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, 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 1d03c65..4518838 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 = "", @@ -27,6 +32,8 @@ data class ProxyConfig( val routeAllApps: Boolean = true, 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, @@ -227,6 +234,8 @@ data class GlobalConnectionSettings( val routeAllApps: Boolean = true, 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, @@ -241,6 +250,8 @@ data class GlobalConnectionSettings( routeAllApps = routeAllApps, bypassLocalNetworks = bypassLocalNetworks, mtu = mtu, + strictSourceValidation = strictSourceValidation, + sourceValidationLevel = sourceValidationLevel, ) } diff --git a/app/src/main/java/net/megaproxy487/vpn/BridgeCallbacks.kt b/app/src/main/java/net/megaproxy487/vpn/BridgeCallbacks.kt index fa9f07d..2b1fed7 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 66034e2..4d38365 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.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() }, + 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, config.sourceValidationLevel.udpRecheckMillis) + } 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 22b48bf..2d1fc47 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) + sourceValidationLevelActive = config.sourceValidationLevel.takeIf { config.strictSourceValidation } isRunning = true true } @@ -861,6 +864,7 @@ class ProxyVpnService : VpnService() { tunnelTestOnly = false activeSession = null isRunning = false + sourceValidationLevelActive = null result } SubscriptionNotifications.cancel(this) @@ -967,6 +971,9 @@ class ProxyVpnService : VpnService() { .build() companion object { + @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 7e37985..f13a873 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -589,4 +589,21 @@ Введите целое число от 1280 до 65535. Для всех профилей на этом устройстве, после переподключения. До 7 HTTPS URL, по одному на строку. + Строгая проверка источника + Проверяет приложение-источник TCP и UDP, блокирует неизвестных владельцев и ping. Может замедлить установление TCP-соединений и передачу UDP, нарушить работу приложений. Применяется при переподключении. + Требуется Android 10 или новее. + Защита источника трафика + Работает + Выключена + 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 3115218..b020000 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -590,4 +590,21 @@ 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 + 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. + 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 f640bed..e5bf2cf 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, 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 new file mode 100644 index 0000000..1f59f59 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/SourceProtectionTest.kt @@ -0,0 +1,63 @@ +package net.megaproxy487 + +import net.megaproxy487.vpn.BridgeSourceValidator +import net.megaproxy487.model.SourceValidationLevel +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, 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, 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 new file mode 100644 index 0000000..eee849f --- /dev/null +++ b/app/src/test/java/net/megaproxy487/StrictSourceSettingsUiTest.kt @@ -0,0 +1,50 @@ +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 + +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 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) {} } + 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) + 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/index.md b/docs/en/index.md index 0f6e848..c882073 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/release-testing.md b/docs/en/release-testing.md index dc4af2c..4d4b203 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 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 new file mode 100644 index 0000000..e12db30 --- /dev/null +++ b/docs/en/source-validation.md @@ -0,0 +1,47 @@ +# 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. + +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. 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 +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 +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/index.md b/docs/ru/index.md index ab98e0e..697a958 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/release-testing.md b/docs/ru/release-testing.md index e88b1fe..d50219a 100644 --- a/docs/ru/release-testing.md +++ b/docs/ru/release-testing.md @@ -135,3 +135,11 @@ 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-порта (немедленный отказ на расширенном уровне, фиксированное +окно 1 секунду без продления на стандартном), Echo обоих +семейств и сохранение ошибок MTU проверяют детерминированные native/JVM-тесты. +Выключенный режим не устанавливает фильтрующие обёртки. Статус видимости не заменяет тест +обхода другим UID; для ручного воспроизведения используйте контролируемое исключённое приложение. diff --git a/docs/ru/source-validation.md b/docs/ru/source-validation.md new file mode 100644 index 0000000..a51ccfc --- /dev/null +++ b/docs/ru/source-validation.md @@ -0,0 +1,50 @@ +# Строгая проверка источника + +[English](../en/source-validation.md) · [Документация](index.md) + +В **Раздельном туннелировании** доступна опция **Строгая проверка источника** для Android 10+. +Применяется при переподключении VPN, по умолчанию выключена, хранится локально и не входит +в экспорт профилей и подписки. При выключенной опции нативный стек использует прежние +обработчики: без запросов UID, кешей проверки и дополнительных проверок пакетов. + +Оба уровня проверяют владельца TCP один раз до выхода через прокси или напрямую в LAN. +Это добавляет задержку при установлении соединения; повторных проверок на TCP-данных нет. +UDP, включая DNS, проверяется при создании потока. Для UDP доступны два уровня: + +| Уровень | Проверка владельца UDP | Компромисс | +| --- | --- | --- | +| Стандартный (первоначальный выбор) | При создании потока и на первой исходящей датаграмме после фиксированного интервала 1 секунду | Меньше запросов Android API; повторно использованный порт может временно наследовать прежнее разрешение | +| Расширенный | При создании потока и на каждой исходящей датаграмме | Быстрее обнаруживает смену владельца; больше нагрузка и задержка | + +Кеш стандартного уровня принадлежит только конкретному живому потоку: он не разделяется +по исходному порту, между адресатами или следующими потоками. Попадания в кеш не продлевают +срок; фоновый таймер не нужен. После истечения срока следующая датаграмма требует новой +проверки до передачи. При отказе поток закрывается. Между проверками сознательно допускается +окно устаревшего разрешения до 1 секунды; этот уровень не равнозначен расширенному. +Ни один уровень не делает запрос владельца и передачу атомарной операцией ядра. + +Неизвестный владелец и ошибка API при проверке блокируются. В split-режиме разрешены только +выбранные UID; в глобальном требуется определимый владелец. Приложения с общим UID +неразличимы. Медленные проверки новых потоков идут вне общего цикла TUN с ограничением +64 одновременных допусков. Не более 8 запросов владельца одновременно входят в Android/JNI; +лимит общий для активных и завершающихся сеансов. Лишние запросы блокируются. Бюджет +запросов создаётся только при включении защиты. Числовые адреса UDP готовятся один раз +для потока. Для обычных пакетов фильтр ICMP читает только короткие заголовки; необычно +длинная цепочка расширений использует одну копию пакета вместо повторного объединения +растущих префиксов. +При ошибке любого направления строгий UDP-relay закрывает TUN и upstream, чтобы отказ +владельцу не удерживал простаивающую ассоциацию с прокси до обычного UDP-таймаута. Оба уровня могут нарушать работу +приложений и трафика без определимого владельца, например раздачи интернета. +Прямого обхода блокировки нет. + +Уровень тоже хранится локально и применяется при переподключении. Ранее включённая опция +без указанного уровня переносится в расширенный режим с сохранением поведения; +при первом включении выбирается стандартный. Видимость показывает фактически работающий +уровень, в том числе когда его смена ещё ожидает переподключения. + +Echo (ping) IPv4/IPv6 блокируется до userspace-стека; обычные ошибки ICMP обрабатываются +штатно. Фрагментированный ICMP и IPv6-фрагменты с неопределимым транспортом также блокируются. +ICMP физического соединения не затрагивается. TCP/HTTP-диагностика приложения продолжает +работать. **Проверка видимости** показывает защиту текущего туннеля и необходимость +переподключения. Это статус сеанса, а не проверка атаки из другого приложения; +он не скрывает VPN-интерфейсы и другие системные признаки VPN. diff --git a/native/mobile/mobile.go b/native/mobile/mobile.go index d326e30..6b82992 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, 0) +} + +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 @@ -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, udpRecheck)) if err != nil { dev.Close() if proxyCloser != nil { diff --git a/native/mobile/robustness_test.go b/native/mobile/robustness_test.go index a6138e6..9ddc3fb 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 new file mode 100644 index 0000000..1a63489 --- /dev/null +++ b/native/mobile/source_icmp.go @@ -0,0 +1,119 @@ +package mobile + +import ( + "encoding/binary" + "gvisor.dev/gvisor/pkg/buffer" + + "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) { + 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 blockSourceEchoPrefix(prefix func(int) ([]byte, bool)) bool { + p, ok := prefix(1) + if !ok { + return true + } + switch p[0] >> 4 { + case 4: + p, ok = prefix(20) + if !ok { + return true + } + if p[9] != 1 { + return false + } + offset := int(p[0]&15) * 4 + if offset < 20 { + return true + } + if binary.BigEndian.Uint16(p[6:8])&0x3fff != 0 { + return true + } + p, ok = prefix(offset + 1) + return !ok || p[offset] == 0 || p[offset] == 8 + case 6: + p, ok = prefix(40) + if !ok { + return true + } + next, offset := p[6], 40 + for steps := 0; steps < 16; steps++ { + switch next { + case 58: + 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 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: + p, ok = prefix(offset + 8) + if !ok { + return true + } + fragmentNext := p[offset] + if binary.BigEndian.Uint16(p[offset+2:offset+4])&0xfff8 != 0 { + return fragmentNext != 6 && fragmentNext != 17 + } + if fragmentNext == 58 { + return true + } + next, offset = fragmentNext, offset+8 + default: + return false + } + } + return true + default: + return true + } +} diff --git a/native/mobile/source_relay.go b/native/mobile/source_relay.go new file mode 100644 index 0000000..382aadb --- /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 0000000..28ed7d8 --- /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 new file mode 100644 index 0000000..b98e8bb --- /dev/null +++ b/native/mobile/source_validation.go @@ -0,0 +1,166 @@ +package mobile + +import ( + "errors" + "net" + "sync" + "time" + + "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, udpRecheckMillis int) error { + if validator == nil { + return errors.New("strict source validator is required") + } + 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) +} + +// 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 + slots chan struct{} + udpRecheck time.Duration +} + +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() { + guarded := newSourceUDP(conn, t.validator, t.udpRecheck) + if !guarded.query() { + _ = conn.Close() + return + } + if t.udpRecheck > 0 { + guarded.recheckAt = time.Now().Add(t.udpRecheck) + } + t.TransportHandler.HandleUDP(guarded) + }) +} + +type sourceUDP struct { + adapter.UDPConn + 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 c.query() + } + 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 !c.query() { + 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) && !c.allowed() { + _ = 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) && !c.allowed() { + _ = 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 dnsTransport, validator SourceValidator, udpRecheck time.Duration) *core.Config { + cfg := &core.Config{LinkEndpoint: endpoint, TransportHandler: handler} + if validator != nil { + 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 new file mode 100644 index 0000000..14fb0f8 --- /dev/null +++ b/native/mobile/source_validation_test.go @@ -0,0 +1,412 @@ +package mobile + +import ( + "fmt" + "gvisor.dev/gvisor/pkg/buffer" + "net" + "sync/atomic" + "testing" + "time" + + "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), 0} + 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 := dnsTransport{} + 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{}, 0) + 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), 0} + 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 +} + +func TestStandardUDPValidationWindowIsFixedAndFlowLocal(t *testing.T) { + v := &sourceTestValidator{allowed: true} + now := time.Unix(100, 0) + conn := newSourceUDP(&sourceTestConn{}, v, 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 := newSourceUDP(&sourceTestConn{}, v, 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 := newSourceUDP(&sourceTestConn{}, v, 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") + } + } +} + +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") + } +} diff --git a/scripts/android_device_tests.py b/scripts/android_device_tests.py index 8600cda..b2e2842 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 8cde69f..a3fc1f5 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)))