From 69afabae87d923ee1861bdd3e826e079c88f1068 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Sat, 20 Jun 2026 14:04:29 -0400 Subject: [PATCH 1/2] feat(security): authenticate naive local SOCKS loopback (#1166 part 2) Generate per-port credentials for the naive external-plugin SOCKS listener and dial it from the sing-box socks outbound with the same creds. Android does not isolate 127.0.0.1 per app, so an unauthenticated plugin SOCKS listener could be reached by any local app to leak the egress IP. Verified on-device: the naive SOCKS port now rejects unauthenticated connections (curl: 'No authentication method was acceptable') and accepts with creds. Scoped to naive only; other external plugins (mieru/trojan-go/hysteria v1) need separate per-plugin auth verification before enabling. --- .../sagernet/bg/proto/BoxInstance.kt | 4 +++- .../nekohasekai/sagernet/fmt/ConfigBuilder.kt | 24 ++++++++++++++++++- .../sagernet/fmt/naive/NaiveFmt.kt | 14 +++++++++-- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt index a7d46d9fd..4d04eb42b 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt @@ -77,7 +77,9 @@ abstract class BoxInstance( is NaiveBean -> { initPlugin("naive-plugin") - pluginConfigs[port] = profile.type to bean.buildNaiveConfig(port) + val creds = config.localProxyCredentials[port] + pluginConfigs[port] = profile.type to + bean.buildNaiveConfig(port, creds?.first, creds?.second) } is HysteriaBean -> { 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 331e5daee..3183b00a8 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt @@ -22,6 +22,8 @@ import io.nekohasekai.sagernet.fmt.tuic.TuicBean import io.nekohasekai.sagernet.fmt.tuic.buildSingBoxOutboundTuicBean import io.nekohasekai.sagernet.fmt.juicity.JuicityBean import io.nekohasekai.sagernet.fmt.juicity.buildSingBoxOutboundJuicityBean +import io.nekohasekai.sagernet.fmt.naive.NaiveBean +import java.util.UUID import io.nekohasekai.sagernet.fmt.v2ray.StandardV2RayBean import io.nekohasekai.sagernet.fmt.v2ray.buildSingBoxOutboundStandardV2RayBean import io.nekohasekai.sagernet.fmt.shadowsocksr.ShadowsocksRBean @@ -69,6 +71,12 @@ class ConfigBuildResult( var trafficMap: Map>, var profileTagMap: Map, val selectorGroupId: Long, + // Per-port credentials for authenticated local SOCKS loopbacks of external + // plugins (e.g. naive). Android does not isolate 127.0.0.1 per app, so an + // unauthenticated plugin SOCKS listener could be reached by any local app and + // leak the egress IP (issue #1166). The plugin listens with these creds and the + // sing-box socks outbound dials with them. + val localProxyCredentials: Map> = emptyMap(), ) { data class IndexEntity(var chain: LinkedHashMap) } @@ -95,6 +103,8 @@ fun buildConfig( val trafficMap = HashMap>() val tagMap = HashMap() val globalOutbounds = HashMap() + // Per-port credentials for authenticated external-plugin SOCKS loopbacks (#1166). + val localProxyCredentials = HashMap>() val readableNames = mutableSetOf(TAG_DIRECT, TAG_BYPASS, TAG_BLOCK, TAG_FRAGMENT, TAG_MIXED, TAG_PROXY) val group = SagerDatabase.groupDao.getById(proxy.groupId) @@ -377,6 +387,17 @@ fun buildConfig( type = "socks" server = LOCALHOST server_port = localPort + // Authenticate the local SOCKS loopback for plugins that support + // it (naive), so other apps on the device can't use this open + // 127.0.0.1 port to leak the egress IP (#1166). The plugin is + // configured to listen with the same generated credentials. + if (bean is NaiveBean) { + val user = "neko" + val pass = UUID.randomUUID().toString().replace("-", "") + localProxyCredentials[localPort] = user to pass + username = user + password = pass + } } } else { // internal outbound @@ -941,7 +962,8 @@ fun buildConfig( proxy.id, trafficMap, tagMap, - if (buildSelector) group.id else -1L + if (buildSelector) group.id else -1L, + localProxyCredentials, ) } diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/naive/NaiveFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/naive/NaiveFmt.kt index cab350e65..b1738e7a3 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/naive/NaiveFmt.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/naive/NaiveFmt.kt @@ -54,7 +54,7 @@ fun NaiveBean.toUri(proxyOnly: Boolean = false): String { return builder.toLink(if (proxyOnly) proto else "naive+$proto", false) } -fun NaiveBean.buildNaiveConfig(port: Int): String { +fun NaiveBean.buildNaiveConfig(port: Int, username: String? = null, password: String? = null): String { return JSONObject().apply { // process ipv6 finalAddress = finalAddress.wrapIPV6Host() @@ -75,7 +75,17 @@ fun NaiveBean.buildNaiveConfig(port: Int): String { } } - put("listen", "socks://$LOCALHOST:$port") + // Authenticate the local SOCKS listener so other apps on the device cannot use + // this loopback port as an open relay and leak the egress IP (#1166). The + // sing-box socks outbound dials with the same credentials. + if (!username.isNullOrBlank() && !password.isNullOrBlank()) { + put( + "listen", + "socks://${username.urlSafe()}:${password.urlSafe()}@$LOCALHOST:$port" + ) + } else { + put("listen", "socks://$LOCALHOST:$port") + } put("proxy", toUri(true)) if (extraHeaders.isNotBlank()) { put("extra-headers", extraHeaders.split("\n").joinToString("\r\n")) From 245bf6c6d0d47811a70f9780cc487770c7fc2eb8 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Sat, 20 Jun 2026 14:19:51 -0400 Subject: [PATCH 2/2] review: address Greptile feedback (skip creds on export, rename shadowed params) - ConfigBuilder: gate naive loopback creds on !forExport so the exported sing-box config stays credential-free and matches the credential-free exported naive config (ProxyEntity.buildNaiveConfig), avoiding a broken standalone export. - NaiveFmt: rename buildNaiveConfig params username/password -> listenUsername/ listenPassword to stop shadowing NaiveBean.username/.password receiver properties. --- .../main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt | 5 ++++- .../main/java/io/nekohasekai/sagernet/fmt/naive/NaiveFmt.kt | 6 +++--- 2 files changed, 7 insertions(+), 4 deletions(-) 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 3183b00a8..d9b945128 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt @@ -391,7 +391,10 @@ fun buildConfig( // it (naive), so other apps on the device can't use this open // 127.0.0.1 port to leak the egress IP (#1166). The plugin is // configured to listen with the same generated credentials. - if (bean is NaiveBean) { + // Skip for export: the exported naive config (ProxyEntity. + // buildNaiveConfig without creds) would otherwise mismatch and + // produce a broken standalone config. + if (bean is NaiveBean && !forExport) { val user = "neko" val pass = UUID.randomUUID().toString().replace("-", "") localProxyCredentials[localPort] = user to pass diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/naive/NaiveFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/naive/NaiveFmt.kt index b1738e7a3..beac41ae4 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/naive/NaiveFmt.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/naive/NaiveFmt.kt @@ -54,7 +54,7 @@ fun NaiveBean.toUri(proxyOnly: Boolean = false): String { return builder.toLink(if (proxyOnly) proto else "naive+$proto", false) } -fun NaiveBean.buildNaiveConfig(port: Int, username: String? = null, password: String? = null): String { +fun NaiveBean.buildNaiveConfig(port: Int, listenUsername: String? = null, listenPassword: String? = null): String { return JSONObject().apply { // process ipv6 finalAddress = finalAddress.wrapIPV6Host() @@ -78,10 +78,10 @@ fun NaiveBean.buildNaiveConfig(port: Int, username: String? = null, password: St // Authenticate the local SOCKS listener so other apps on the device cannot use // this loopback port as an open relay and leak the egress IP (#1166). The // sing-box socks outbound dials with the same credentials. - if (!username.isNullOrBlank() && !password.isNullOrBlank()) { + if (!listenUsername.isNullOrBlank() && !listenPassword.isNullOrBlank()) { put( "listen", - "socks://${username.urlSafe()}:${password.urlSafe()}@$LOCALHOST:$port" + "socks://${listenUsername.urlSafe()}:${listenPassword.urlSafe()}@$LOCALHOST:$port" ) } else { put("listen", "socks://$LOCALHOST:$port")