From bd9c671da018ab5eb30395ff222d3e3724068f7b Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Sat, 20 Jun 2026 11:13:01 -0400 Subject: [PATCH 1/8] fix(import): raise SnakeYAML alias cap to 1000 for large Clash subs (#1042) --- .../io/nekohasekai/sagernet/group/RawUpdater.kt | 6 ++++++ release.keystore | Bin 4484 -> 0 bytes 2 files changed, 6 insertions(+) delete mode 100644 release.keystore diff --git a/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt b/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt index 1e8aeb3f8..93e260eab 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt @@ -267,6 +267,12 @@ object RawUpdater : GroupUpdater() { // tag; a YAML mapping is returned as a LinkedHashMap natively. val loaderOptions = LoaderOptions().apply { codePointLimit = 10 * 1024 * 1024 // 10 MiB + // SnakeYAML 2.x defaults maxAliasesForCollections to 50 as a + // billion-laughs guard. Legitimate large Clash/Mihomo configs + // reuse anchors heavily and exceed 50 (issue #1042). Raise to a + // finite cap (not Int.MAX_VALUE); the codePointLimit above still + // bounds total input so amplification stays bounded. + maxAliasesForCollections = 1000 } // In SnakeYAML 2.x, Yaml(BaseConstructor) adopts the constructor's // LoaderOptions (getLoadingConfig()), so codePointLimit set above is diff --git a/release.keystore b/release.keystore deleted file mode 100644 index 7b58d27c8de31e0a0d8a87cd53d57236d6d94f34..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4484 zcma)ARZtuXkX&qWSv(M&;IhCHT!T9a1h?SM;u>JlT_i|wcYC8VK}UR(@cLC9T4|_TzEJrsQEDLUN8(B3AXvifq@S;01N-y;(q%Q1dj)P7Vg3A3sk? zxgI6kh89(vAXXwJN{~}PKR4y7Wm?T|<&`w`$eYmWQ?>|wrfP>Niu@_{qDg7U^Q9D9 z!X8o7EWD2ZA5obp+AglS4#Jn|VIcqIi7TP%^6B7+{;=oCHaw_b-Fu^8P{rdJaRTYb z&8$(09ihl6G@q-#W}Cp~zCcz>mU>%Y7w9eG|AnG}^}5>^Dn&y?<8V@TnzDU;NMs*r ze<{sy5bt}Vn?3%55{E-Y25BKu&vBknbU0B$mP%l4v7uO_6~Z|Ayl9! zM*Lv}Z(IKEQaFz^A6oPwu^QD}x-b@ak4AmH>LXQjge!44q+8O%LRhR$vIVyScc|G{ zIm`7mFS&4=c>R?ht+RKcsfAx7!5Nq1mgx8He)hrRN4IU$x+?kn>%}M;Mp@RN=QR)2 zh*vonPqgRnn^J5yYJ8jzwlCU^Yi^VAlKTVn4VB=v& zE-?RT-tC)fr`YaaqRBDBryTT|Gt@Wk!trAEU_q7J(giw?jlJ~(^=s6Ld+(9s7kauV%)aboD7!j-@-v9Ml6+X@iT1YSq?1_tW+fv+q4&Axyih;sKFWe! z0E3vWV)}Mb`!&i-8X;lg)@JSNJE&PEp#6s`_QTG}SS!98BXz4MY9E$AzCo$%E|b}r ze}!u0;(IGx`oXw_F>GG$H!fs^mi1D_ex1TCdBNU?vP7BhRBY;P?W59t+Xbvw3#T*3 zJHcfid@eiBsVhFC{*m)kJ%FtjO3C1l5Nt0^e&kXUxn+yIVPt1kEk5E*3)64UG`Mcd zo=|4ilC#06`i(pq15glzqzJVwg>BgFakqMTIL-m`H-B@g2t*nk9+THj8X=8ceCIlX zXOeZq$>`8EuGWkr@p`iPvCok5SS(Uw@QO^V0HrAG$_b&CZm91Hy)*4l)zC~tCbum^ zb_Z&{=6Uuc)yj3xeBGVlZ}Xi6^!k;CZzVLduHBhc^Y6>T#F~IZg~6{5({bx}z7p&r zH`lmHA-O#AA}_3YIRu1|QL`Qkd)UyA#g1du@BegJYm1vZ*QMb|7L2Z()?Yh(eaUOYVVIcn|egIp?{m!EYE-*3=pDF9$ zSa}8%yndJv7+2`@5r)|se$-wW-Wz}xsllSb^!i;QbMR$xr|qD$gOSZ0XZt%RZo5M% z5J>{!1-!%4LQ8}2m0Y;?B5qXF@b!0Y+m<%-y)r0^6K+j|H@ddbV)u|(jl%cd(r?R< zL&+!A7N##T&6%q(l;yYXPglsFjZPW9b}2jWtF#DY^Td7HUL-?Z^yfaUG8!*B)9)&} ze8t@&t(IXpc8-n?ZcWI)j!24@y2tjSJL*9kWqdO*%Rqg|u#6SqSiQ^=BB~Lq<##Uep-#xpdYDsn!F6TK~ zc-Q|_4(Da0Wj2@x)*Oytn7nX8w8&4MrFMEZ*BXa*2m0vy|pF{BO!iA z)S9|lok8rOW&EL1i05fqTpdaIcES-&=mXYxK;5Ou5}Sc5r6>Wf3l=@zi=dbU2!1Kf z@3eurdXq$8h+A~&9Ec5ZLbTc}m+{PNL(`~@`ME(jUuc4PJib>n%{)&DC=u1f)onM9 z_3ApfhNV`_dafDYc(B0nf=mN*24`BXYR`+a9IzpDXWue!rq`J0)j4@AdfA$in6w_f zag)ldBL)%om)q23y_*=KqUyOgYPP?Moe75HVX68u^hT{bqo#EeP-$uUHWbwYnL#j0 z2`o0?%qaux7lpApa!%&0RrzYhDf0{oTf+9h-AQtc-m^noFS`FBv9A)SE2&X0)k85B zWnU=ABW@;SOOJ3ZAjjuYB91%fVD0)#V;gxW5?bRx)B&K7!Whvps9qTMHmc|I`Dz&H z4e!7bBAOiI__}^`{jis;4Rk;HDIQ70FXx%cUIoWWOwV@@stY!PfC}HZ_IRAf#r;&R zNCoT2N?b}?@E(4Jd-jCIzS$O0j}R6rs2_*$@7jmpi+rsCH3iu2CYN*5>ei@)of4|r z&|Y;XE^ovbL`r*a4o(X>$~EGZo7GFpkCDG_Bs;QGo~Io75Y*XrhM1Cr`r3(<&2?ki zho;zwfxm+e2g82>z6Nte84k@`Bw?PtOD;7{WLaAmjSZT(XdF24dj?zF92OQ<5jzlP zNtR;}TxpZ9tI6u6fFu!J<(nuj!o&jM8vD}E*?DYcSwSbHhka0>H(q5Eg65eYlT3@|G22Ob^HQH% zM~U3%IG-Ar^`E$gP0B|_O9^lW_yM>9jsWZbLJNQsz#d=$@BlOXDHDDQG!C4@vW z+jlY}4MOt&iHwMSNoaIge_(E~Ob*(BR}c8s#Id8{!L(%IBr1L?NsLYgm=y>aNoG0vaWh z*gC5RJbjfMvxf56gh^2gU}CB?4Tf`)7Iis0fB(f2Pb<3gH7QPNNV^zbfm^g^5*ato zq9;oxs}~jNgh8vA^b0v-sh-9vkw~X-+!VZE4 z`B2}@LTu}fwD#)?JRM5dOJtT?bwX1IDY=WFT-phSY6Zsx65017_l?_y9M;H0{;XA? z&pd@xG3P5xRadp1M(z9`)5fY|b_v`1dj;OUSx}b3CTa_08(C>WyeSX&;AL4Zi-}@*#D7Ve7Y9$qPw1LX zLE3GG9bfpISWp*7MBmy{6%p)wH=urP=9?@WcYA^~Z8YK1r&5jJxs|##mDp4kwJaPs z-aSDJTb($EIjwnBiBL(RW)AQY7D?*V99JK7Ad{cmLT*WesSIOzvc{o*_R~17kN-Sx z&TLQQbT}ty+c5tqgVSp7p5giZv`S=-)w~~KU|AJ(dc&WNg>U2+4Y}2yT(Bu|pFRG< zEGEyl;(;%Ax6fQYP^P`d$4h1^i%X9##=VLx8cVpF)o3r{Y`c55J*teHlg*G4tj|Tj zO1slnmu@#&Dq~84fe1l!cFz(biM?^{$Loix(A->Z52+`LLS0(yjkC%r2$8{J z`1;q+#9mtIA(T0+{V_G%Q&GJ1JnpAoD#F9-&s5f3cF(Z~3gO9i z_5-61*Gf9^7u}@CvYN|grPs`fz9DCYw?vKMwsnX4>F-8vOSLfl?YKf8H?p)%0pSms zp$!pLojEyWl#Zzrpo;fhz{$wP`Y&(Kp%t$HR?YRzgxqAD$i8ab+jJ?Ge5aSW>sFja z9A_0Wsos>*PgP?&^_o@tgF=(g`?;uFw=i<40uectox7?u4$EHHbSNxh*d9I<-vHUrlMihM6g{pmI$VR-y}F=qV!H1_g$_Z*1tc#YG8&U#YW4hEq+?V9ii9XDcI^O)X(@JG7=eO&(= zf>;8F>}W~LjjdGAKn*4+0ZX_%Q`NQ0j{J^`BF8r}lTwlpI#FZpHS3J3kOqCwxbbj} z*oeT(w`jBL-<;0wrN#$!!&|@H+(!EAuQPszi?YKix>Foy%f%P#wXH05CRQU?{sM-F z5!lce(BUZb>7o^TG?<5O(0z1Fxpz+~MGa%tLjSm(DvaYFp-FZ|s^(Jp@i36q76{rx z1PRq}8kv}H(j`iC)Rqp*)(%ZWywCyR!o;)EOhwPNUwA$nV4hnXGS(bG)2}$n23$Tx zEHZkvR%-`n;~(tckR=VVZghJrBmw;cI*X*X9*8_0Km8&MR&>N6y_;`BKg>w_rl+XJ z+C`pL(*afo^MUdI`AndqU;xnR+uMrt**oL{zhvY Date: Sat, 20 Jun 2026 11:13:01 -0400 Subject: [PATCH 2/8] fix(ui): guard About screen against detached-fragment crash (#1192) --- .../java/io/nekohasekai/sagernet/ui/AboutFragment.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/AboutFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/AboutFragment.kt index 75a64a82a..597d23b1e 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/AboutFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/AboutFragment.kt @@ -104,7 +104,7 @@ class AboutFragment : ToolbarFragment(R.layout.layout_about) { .addItem( MaterialAboutActionItem.Builder() .icon(R.drawable.ic_baseline_layers_24) - .text(getString(R.string.version_x, "sing-box")) + .text(activityContext.getString(R.string.version_x, "sing-box")) .subText(Libcore.versionBox()) .setOnClickAction { } .build()) @@ -130,7 +130,7 @@ class AboutFragment : ToolbarFragment(R.layout.layout_about) { MaterialAboutActionItem.Builder() .icon(R.drawable.ic_baseline_nfc_24) .text( - getString( + activityContext.getString( R.string.version_x, pluginId ) + " (${Plugins.displayExeProvider(pkg.packageName)})" @@ -248,6 +248,11 @@ class AboutFragment : ToolbarFragment(R.layout.layout_about) { haveUpdate && !releaseName.contains(BuildConfig.VERSION_NAME) } runOnMainDispatcher { + // The async work above may outlive the fragment's attachment + // (e.g. user navigates away). Touching requireContext()/app + // resources while detached throws IllegalStateException + // (issue #1192). Bail out if no longer attached. + if (!isAdded) return@runOnMainDispatcher if (haveUpdate) { val context = requireContext() MaterialAlertDialogBuilder(context) @@ -272,6 +277,7 @@ class AboutFragment : ToolbarFragment(R.layout.layout_about) { } catch (e: Exception) { Logs.w(e) runOnMainDispatcher { + if (!isAdded) return@runOnMainDispatcher Toast.makeText(app, e.readableMessage, Toast.LENGTH_SHORT).show() } } From c5bf11cb3476a11e8d299a94b3a81d4c4e6e387e Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Sat, 20 Jun 2026 11:13:02 -0400 Subject: [PATCH 3/8] fix(ui): replace Filter with coroutine filtering to avoid per-apps OOM (#1086) --- .../sagernet/ui/AppManagerActivity.kt | 65 +++++++++++-------- 1 file changed, 39 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/AppManagerActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/AppManagerActivity.kt index effbc5823..8309acc9f 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/AppManagerActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/AppManagerActivity.kt @@ -13,8 +13,6 @@ import android.view.Menu import android.view.MenuItem import android.view.View import android.view.ViewGroup -import android.widget.Filter -import android.widget.Filterable import androidx.annotation.UiThread import androidx.core.util.contains import androidx.core.util.set @@ -42,7 +40,9 @@ import io.nekohasekai.sagernet.utils.PackageCache import io.nekohasekai.sagernet.widget.ListListener import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import moe.matsuri.nb4a.utils.NGUtil import kotlin.coroutines.coroutineContext @@ -101,7 +101,6 @@ class AppManagerActivity : ThemedActivity() { } private inner class AppsAdapter : RecyclerView.Adapter(), - Filterable, FastScrollRecyclerView.SectionedAdapter { var filteredApps = apps @@ -130,26 +129,25 @@ class AppManagerActivity : ThemedActivity() { override fun getItemCount(): Int = filteredApps.size - private val filterImpl = object : Filter() { - override fun performFiltering(constraint: CharSequence) = FilterResults().apply { - var filteredApps = if (constraint.isEmpty()) apps else apps.filter { - it.name.contains(constraint, true) || it.packageName.contains( - constraint, true - ) || it.uid.toString().contains(constraint) - } - if (!sysApps) filteredApps = filteredApps.filter { !it.sys } - count = filteredApps.size - values = filteredApps - } - - override fun publishResults(constraint: CharSequence, results: FilterResults) { - @Suppress("UNCHECKED_CAST") - filteredApps = results.values as List - notifyDataSetChanged() + // Replaces android.widget.Filter, which spawned a new worker thread per + // filter() call (OutOfMemoryError: pthread_create failed, issue #1086). + // Computes the filtered list off the main thread and publishes on main; + // callers cancel the previous job via applyFilter() below. + fun computeFiltered(constraint: CharSequence): List { + var result = if (constraint.isEmpty()) apps else apps.filter { + it.name.contains(constraint, true) || it.packageName.contains( + constraint, true + ) || it.uid.toString().contains(constraint) } + if (!sysApps) result = result.filter { !it.sys } + return result } - override fun getFilter(): Filter = filterImpl + fun publishFiltered(result: List) { + filteredApps = result + @Suppress("NotifyDataSetChanged") + notifyDataSetChanged() + } override fun getSectionName(position: Int): String { return filteredApps[position].name.firstOrNull()?.toString() ?: "" @@ -162,9 +160,24 @@ class AppManagerActivity : ThemedActivity() { private lateinit var binding: LayoutAppsBinding private val proxiedUids = SparseBooleanArray() private var loader: Job? = null + private var filterJob: Job? = null private var apps = emptyList() private val appsAdapter = AppsAdapter() + // Coroutine-based replacement for the old Filter.filter(...) calls. Cancels the + // in-flight filter job (so rapid keystrokes don't pile up work) and computes the + // filtered list on a background dispatcher before publishing on main. + // debounceMs > 0 is used for the search box to avoid filtering on every keystroke. + @UiThread + private fun applyFilter(constraint: String = binding.search.text?.toString() ?: "", debounceMs: Long = 0) { + filterJob?.cancel() + filterJob = lifecycleScope.launch { + if (debounceMs > 0) delay(debounceMs) + val result = withContext(Dispatchers.Default) { appsAdapter.computeFiltered(constraint) } + appsAdapter.publishFiltered(result) + } + } + private fun initProxiedUids(str: String = DataStore.individual) { proxiedUids.clear() val apps = cachedApps @@ -184,7 +197,7 @@ class AppManagerActivity : ThemedActivity() { loading.crossFadeFrom(binding.list) val adapter = binding.list.adapter as AppsAdapter withContext(Dispatchers.IO) { adapter.reload() } - adapter.filter.filter(binding.search.text?.toString() ?: "") + applyFilter() if (apps.isEmpty()) { binding.list.visibility = View.GONE binding.appPlaceholder.root.crossFadeFrom(loading) @@ -241,13 +254,13 @@ class AppManagerActivity : ThemedActivity() { ViewCompat.setOnApplyWindowInsetsListener(binding.root, ListListener) binding.search.addTextChangedListener { - appsAdapter.filter.filter(it?.toString() ?: "") + applyFilter(it?.toString() ?: "", debounceMs = 250) } binding.showSystemApps.isChecked = sysApps binding.showSystemApps.setOnCheckedChangeListener { _, isChecked -> sysApps = isChecked - appsAdapter.filter.filter(binding.search.text?.toString() ?: "") + applyFilter() } instance = this @@ -277,7 +290,7 @@ class AppManagerActivity : ThemedActivity() { .joinToString("\n") { it.packageName } apps = apps.sortedWith(compareBy({ !isProxiedApp(it) }, { it.name.toString() })) onMainDispatcher { - appsAdapter.filter.filter(binding.search.text?.toString() ?: "") + applyFilter() } } @@ -290,7 +303,7 @@ class AppManagerActivity : ThemedActivity() { DataStore.individual = "" apps = apps.sortedWith(compareBy({ !isProxiedApp(it) }, { it.name.toString() })) onMainDispatcher { - appsAdapter.filter.filter(binding.search.text?.toString() ?: "") + applyFilter() } } } @@ -363,7 +376,7 @@ class AppManagerActivity : ThemedActivity() { DataStore.individual = apps.filter { isProxiedApp(it) }.joinToString("\n") { it.packageName } apps = apps.sortedWith(compareBy({ !isProxiedApp(it) }, { it.name.toString() })) - appsAdapter.filter.filter(binding.search.text?.toString() ?: "") + applyFilter() } catch (e: Exception) { Logs.e(e) } From 9e1d0f14b6e97715c216d284ebac0e787ea2be12 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Sat, 20 Jun 2026 11:13:02 -0400 Subject: [PATCH 4/8] fix(ui): survive rotation during URL test; guard dialog dismiss (#1141) --- app/src/main/AndroidManifest.xml | 2 +- .../java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bda0d6c62..15bfb7d48 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -74,7 +74,7 @@ diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index 1bff1dcc2..2cbd6ae5a 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -914,7 +914,7 @@ class ConfigurationFragment @JvmOverloads constructor( } test.cancel = { test.dialogStatus.set(2) - dialog.dismiss() + try { dialog.dismiss() } catch (e: Exception) { Logs.w(e) } // dialog window may be gone after rotation (#1141) runOnDefaultDispatcher { mainJob.cancel() testJobs.forEach { it.cancel() } @@ -983,7 +983,7 @@ class ConfigurationFragment @JvmOverloads constructor( } test.cancel = { test.dialogStatus.set(2) - dialog.dismiss() + try { dialog.dismiss() } catch (e: Exception) { Logs.w(e) } // dialog window may be gone after rotation (#1141) runOnDefaultDispatcher { mainJob.cancel() testJobs.forEach { it.cancel() } From d4741affb2adedcf3b45fb166211e4379e914ae7 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Sat, 20 Jun 2026 11:13:03 -0400 Subject: [PATCH 5/8] fix(import): don't abort profile import on stray http link (#1128) --- .../io/nekohasekai/sagernet/ktx/Formats.kt | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/Formats.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/Formats.kt index 2b8ccffb6..0704215b3 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ktx/Formats.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/Formats.kt @@ -113,6 +113,11 @@ suspend fun parseProxies(text: String): List { val entities = ArrayList() val entitiesByLine = ArrayList() + // An http(s) link that fails to parse as an HTTP proxy is a subscription candidate. + // Don't abort import immediately (issue #1128): a file may contain valid profile + // links alongside a plain promo/Telegram URL. Remember the first candidate and only + // treat the input as a subscription if NO profiles parsed at all. + var subscriptionCandidate: String? = null fun String.parseLink(entities: ArrayList) { if (startsWith("clash://install-config?") || startsWith("sn://subscription?")) { @@ -142,14 +147,17 @@ suspend fun parseProxies(text: String): List { entities.add(parseHttp(this)) }.onFailure { Logs.w(it) - val clashUrl = HttpUrl.Builder() - .scheme("https") - .host("install-config") - .addQueryParameter("url", this) - .build() - .toString() - .replaceFirst("https://", "clash://") - throw (SubscriptionFoundException(clashUrl)) + if (subscriptionCandidate == null) { + val clashUrl = HttpUrl.Builder() + .scheme("https") + .host("install-config") + .addQueryParameter("url", this) + .build() + .toString() + .replaceFirst("https://", "clash://") + // Defer: only thrown later if no profile links were parsed. + subscriptionCandidate = clashUrl + } } } else if (startsWith("vmess://")) { Logs.d("Try parse v2ray link: $this") @@ -258,6 +266,12 @@ suspend fun parseProxies(text: String): List { for (link in linksByLine) { link.parseLink(entitiesByLine) } + // No profile links parsed but we saw an unparyable http(s) URL: treat the whole + // input as a subscription link (single-URL paste / file). When profiles WERE found, + // the stray URL is ignored so the profiles still import (issue #1128). + if (entities.isEmpty() && entitiesByLine.isEmpty()) { + subscriptionCandidate?.let { throw SubscriptionFoundException(it) } + } // var isBadLink = false if (entities.onEach { it.initializeDefaultValues() }.size == entitiesByLine.onEach { it.initializeDefaultValues() }.size) run test@{ entities.forEachIndexed { index, bean -> From bc15b8fc44b96a80955408566d645c9a870e7017 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Sat, 20 Jun 2026 11:13:04 -0400 Subject: [PATCH 6/8] feat(security): close/auth local proxy inbound options (#1154, #1197) --- .../java/io/nekohasekai/sagernet/Constants.kt | 2 ++ .../nekohasekai/sagernet/database/DataStore.kt | 14 +++++++++++++- .../nekohasekai/sagernet/fmt/ConfigBuilder.kt | 17 +++++++++++++++-- app/src/main/res/values/strings.xml | 4 ++++ app/src/main/res/xml/global_preferences.xml | 10 ++++++++++ 5 files changed, 44 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/Constants.kt b/app/src/main/java/io/nekohasekai/sagernet/Constants.kt index 62abe81dc..44ad8071f 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/Constants.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/Constants.kt @@ -47,6 +47,8 @@ object Key { const val MIXED_SECRET = "mixedSecret" // storage key for the generated inbound secret const val MIXED_USERNAME = "neko" // username presented to the authed mixed inbound const val ALLOW_ACCESS = "allowAccess" + const val REQUIRE_PROXY_IN_VPN = "requireProxyInVPN" // keep local mixed inbound open in VPN mode + const val PROXY_MODE_INBOUND_AUTH = "proxyModeInboundAuth" // authenticate loopback inbound in Proxy mode const val SPEED_INTERVAL = "speedInterval" const val SHOW_DIRECT_SPEED = "showDirectSpeed" diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt b/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt index ebac2397e..38b72f84f 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt @@ -169,7 +169,19 @@ object DataStore : OnPreferenceDataStoreChangeListener { // inbound is authenticated, except when appendHttpProxy is active on // Android Q+ (the system HTTP proxy cannot supply credentials). (serviceMode == Key.MODE_VPN && - !(appendHttpProxy && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)) + !(appendHttpProxy && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)) || + // Proxy service mode loopback inbound: optionally authenticate too + // (issue #1197 residual gap). Off by default to preserve the + // "open localhost proxy" use case; users opt in for hardening. + (serviceMode == Key.MODE_PROXY && proxyModeInboundAuth) + + // Keep the local mixed (SOCKS/HTTP) inbound open in VPN mode. Default false: in + // TUN mode the local proxy port is usually unnecessary, and closing it removes the + // local port-scan attack surface entirely (PR #1154 / issue #1197). + var requireProxyInVPN by configurationStore.boolean(Key.REQUIRE_PROXY_IN_VPN) + + // Authenticate the loopback mixed inbound in Proxy service mode (issue #1197). + var proxyModeInboundAuth by configurationStore.boolean(Key.PROXY_MODE_INBOUND_AUTH) val mixedInboundUser: String get() = if (mixedInboundAuthed) Key.MIXED_USERNAME else "" val mixedInboundPass: String get() = if (mixedInboundAuthed) mixedSecret else "" diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt index a2b49e313..331e5daee 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt @@ -1,5 +1,6 @@ package io.nekohasekai.sagernet.fmt +import android.os.Build import android.widget.Toast import io.nekohasekai.sagernet.* import io.nekohasekai.sagernet.bg.VpnService @@ -147,6 +148,14 @@ fun buildConfig( val bypassDNSBeans = hashSetOf() val isVPN = DataStore.serviceMode == Key.MODE_VPN val bind = if (!forTest && DataStore.allowAccess) "0.0.0.0" else LOCALHOST + // Whether the local mixed (SOCKS/HTTP) inbound is present in the final config. + // In VPN/TUN mode it is omitted unless the user opts in (requireProxyInVPN) or the + // system HTTP proxy needs it (appendHttpProxy). See issue #1197 / PR #1154. + val keepMixedInbound = !forTest && ( + !isVPN || + DataStore.requireProxyInVPN || + (DataStore.appendHttpProxy && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + ) val remoteDns = DataStore.remoteDns.split("\n") .mapNotNull { dns -> sanitizeDnsEntry(dns).takeIf { it.isNotBlank() && !it.startsWith("#") } } val directDNS = DataStore.directDns.split("\n") @@ -252,7 +261,11 @@ fun buildConfig( } } }) - inbounds.add(Inbound_MixedOptions().apply { + // Local mixed (SOCKS/HTTP) inbound. In VPN/TUN mode this port is usually + // unnecessary and only widens the local attack surface (issue #1197), so it + // is omitted unless the user opts in (requireProxyInVPN) or the system HTTP + // proxy needs it (appendHttpProxy routes the device proxy through this port). + if (keepMixedInbound) inbounds.add(Inbound_MixedOptions().apply { type = "mixed" tag = TAG_MIXED listen = bind @@ -570,7 +583,7 @@ fun buildConfig( outbound = mainProxyTag }) - route.rules.add(Rule_DefaultOptions().apply { + if (keepMixedInbound) route.rules.add(Rule_DefaultOptions().apply { inbound = listOf(TAG_MIXED) outbound = mainProxyTag }) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 950898699..7be22d054 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -62,6 +62,10 @@ Route Settings Allow Connections from the LAN Bind inbound servers to 0.0.0.0 + Keep local proxy in VPN mode + Keep the local SOCKS/HTTP proxy port open while in VPN mode. Off by default: in VPN mode the port is usually unneeded and leaving it open lets other apps reach it. + Authenticate local proxy in Proxy mode + Require username/password on the local SOCKS/HTTP proxy when running in Proxy (non-VPN) mode. Enable to stop other apps from using it as an open relay. Inbound Settings App Settings Enable HTTP inbound diff --git a/app/src/main/res/xml/global_preferences.xml b/app/src/main/res/xml/global_preferences.xml index 72e7d4de0..bd9d9e463 100644 --- a/app/src/main/res/xml/global_preferences.xml +++ b/app/src/main/res/xml/global_preferences.xml @@ -261,6 +261,16 @@ app:key="allowAccess" app:summary="@string/allow_access_sum" app:title="@string/allow_access" /> + + From 0672bb6e61a3b213bc7fb6b714deb42774c1c2ad Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Sat, 20 Jun 2026 11:13:09 -0400 Subject: [PATCH 7/8] chore(security): untrack release.keystore; ignore signing keys --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 9cc17787f..3debf396c 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,8 @@ local.properties # Submodules / external sources /external/ + +# Signing keys — never commit; provided via CI secrets or local.properties +release.keystore +*.keystore +*.jks From e35ffe6430d8b591a859630c315b3d879c898dae Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Sat, 20 Jun 2026 11:29:14 -0400 Subject: [PATCH 8/8] review: address CR/Greptile feedback (alias cap 200, ISE catch, @Volatile, typo) --- .../main/java/io/nekohasekai/sagernet/group/RawUpdater.kt | 7 ++++--- app/src/main/java/io/nekohasekai/sagernet/ktx/Formats.kt | 2 +- .../java/io/nekohasekai/sagernet/ui/AppManagerActivity.kt | 4 ++++ .../io/nekohasekai/sagernet/ui/ConfigurationFragment.kt | 4 ++-- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt b/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt index 93e260eab..08e39d87c 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/group/RawUpdater.kt @@ -270,9 +270,10 @@ object RawUpdater : GroupUpdater() { // SnakeYAML 2.x defaults maxAliasesForCollections to 50 as a // billion-laughs guard. Legitimate large Clash/Mihomo configs // reuse anchors heavily and exceed 50 (issue #1042). Raise to a - // finite cap (not Int.MAX_VALUE); the codePointLimit above still - // bounds total input so amplification stays bounded. - maxAliasesForCollections = 1000 + // finite cap (not Int.MAX_VALUE). 200 covers known real-world + // configs while keeping alias-expansion amplification bounded + // (codePointLimit bounds input size, not the expanded object graph). + maxAliasesForCollections = 200 } // In SnakeYAML 2.x, Yaml(BaseConstructor) adopts the constructor's // LoaderOptions (getLoadingConfig()), so codePointLimit set above is diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/Formats.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/Formats.kt index 0704215b3..ab7db2549 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ktx/Formats.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/Formats.kt @@ -266,7 +266,7 @@ suspend fun parseProxies(text: String): List { for (link in linksByLine) { link.parseLink(entitiesByLine) } - // No profile links parsed but we saw an unparyable http(s) URL: treat the whole + // No profile links parsed but we saw an unparsable http(s) URL: treat the whole // input as a subscription link (single-URL paste / file). When profiles WERE found, // the stray URL is ignored so the profiles still import (issue #1128). if (entities.isEmpty() && entitiesByLine.isEmpty()) { diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/AppManagerActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/AppManagerActivity.kt index 8309acc9f..4b3c6f897 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/AppManagerActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/AppManagerActivity.kt @@ -161,6 +161,9 @@ class AppManagerActivity : ThemedActivity() { private val proxiedUids = SparseBooleanArray() private var loader: Job? = null private var filterJob: Job? = null + // Read on Dispatchers.Default in computeFiltered(); written from Default/IO/Main. + // @Volatile guarantees cross-thread visibility so filtering never sees a stale list. + @Volatile private var apps = emptyList() private val appsAdapter = AppsAdapter() @@ -267,6 +270,7 @@ class AppManagerActivity : ThemedActivity() { loadApps() } + @Volatile private var sysApps = true override fun onCreateOptionsMenu(menu: Menu): Boolean { diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index 2cbd6ae5a..bc7ad1d56 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -914,7 +914,7 @@ class ConfigurationFragment @JvmOverloads constructor( } test.cancel = { test.dialogStatus.set(2) - try { dialog.dismiss() } catch (e: Exception) { Logs.w(e) } // dialog window may be gone after rotation (#1141) + try { dialog.dismiss() } catch (e: IllegalStateException) { Logs.w(e) } // dialog window may be gone after rotation (#1141) runOnDefaultDispatcher { mainJob.cancel() testJobs.forEach { it.cancel() } @@ -983,7 +983,7 @@ class ConfigurationFragment @JvmOverloads constructor( } test.cancel = { test.dialogStatus.set(2) - try { dialog.dismiss() } catch (e: Exception) { Logs.w(e) } // dialog window may be gone after rotation (#1141) + try { dialog.dismiss() } catch (e: IllegalStateException) { Logs.w(e) } // dialog window may be gone after rotation (#1141) runOnDefaultDispatcher { mainJob.cancel() testJobs.forEach { it.cancel() }