Close clearnet leak for Tor users during the startup settings race

Profile fetches and boot-time network callbacks could dial relays
directly before the async settings load finished: Amber.settings
starts as the data-class default (torMode = DISABLED), so the relay
and Coil factories read 'Tor off' and picked the direct OkHttp
client. The fail-closed SOCKS placeholder never got consulted. Seen
in adb logs: kind-0 profile REQs went out over clearnet TLS a full
ten seconds before the built-in Tor SOCKS port existed.

Fix, at the factory decision point:

- Preset the proxy synchronously from plain prefs in
  Application.onCreate: ORBOT gets the configured port, BUILTIN the
  fail-closed placeholder (SOCKS 127.0.0.1:<dynamic>, refused until
  Tor binds), DISABLED stays direct.
- Add Amber.isSettingsLoaded, set when reloadApp() assigns the
  loaded settings. Until then both factories route through the
  proxy client instead of trusting the DISABLED default, so early
  dials (network callbacks, NotificationSubscription /
  ProfileSubscription / BunkerRequestUtils) hit the placeholder and
  retry rather than leak.

Verified on device: cold start with BUILTIN Tor shows pre-Tor dials
refused and every profile REQ flowing after the SOCKS port comes up.
This commit is contained in:
greenart7c3
2026-09-14 09:57:50 -03:00
parent 7d44c39b34
commit 313396559e
2 changed files with 53 additions and 2 deletions
@@ -117,8 +117,25 @@ class Amber :
var settings: AmberSettings = AmberSettings() var settings: AmberSettings = AmberSettings()
/**
* False until the first async settings load ([LocalPreferences.reloadApp]) lands. Until
* then [settings] is the data-class default, whose torMode (DISABLED) is a guess, not a
* decision — any dial in that window must route through the proxy client (preset from
* plain prefs in [onCreate]) instead of trusting the default and going to the clearnet.
*/
@Volatile var isSettingsLoaded = false
val factory = OkHttpWebSocket.Builder { url -> val factory = OkHttpWebSocket.Builder { url ->
val useProxy = if (isPrivateIp(url.url)) false else settings.torMode != TorMode.DISABLED val useProxy = when {
isPrivateIp(url.url) -> false
// Settings load asynchronously; until they land, torMode's default (DISABLED)
// is a guess, not a decision. Route through the proxy client — preset from
// plain prefs in onCreate — so a BUILTIN/ORBOT user's pre-Tor dials (network
// callbacks, profile fetches) hit the fail-closed placeholder instead of the
// clearnet.
!isSettingsLoaded -> true
else -> settings.torMode != TorMode.DISABLED
}
HttpClientManager.getHttpClient(useProxy) HttpClientManager.getHttpClient(useProxy)
} }
@@ -354,6 +371,12 @@ class Amber :
} }
instance = this instance = this
// Before anything can dial: route the proxy client through the user's SOCKS
// setup (or the fail-closed placeholder for BUILTIN) read synchronously from
// plain prefs. Dials in the window before the async settings load — network
// callbacks, profile fetches — pick this up via the factory's !isSettingsLoaded
// gate; without it that window leaks clearnet traffic for Tor users.
LocalPreferences.presetProxyFromPrefs(this)
stats.createNotificationChannel() stats.createNotificationChannel()
Thread.setDefaultUncaughtExceptionHandler(UnexpectedCrashSaver(crashReportCache, applicationIOScope)) Thread.setDefaultUncaughtExceptionHandler(UnexpectedCrashSaver(crashReportCache, applicationIOScope))
@@ -654,7 +677,11 @@ class Amber :
.build() .build()
val coilCallFactory = okhttp3.Call.Factory { request -> val coilCallFactory = okhttp3.Call.Factory { request ->
val url = request.url.toString() val url = request.url.toString()
val useProxy = if (isPrivateIp(url)) false else settings.torMode != TorMode.DISABLED val useProxy = when {
isPrivateIp(url) -> false
!isSettingsLoaded -> true
else -> settings.torMode != TorMode.DISABLED
}
HttpClientManager.getHttpClient(useProxy).newCall(request) HttpClientManager.getHttpClient(useProxy).newCall(request)
} }
return ImageLoader.Builder(context) return ImageLoader.Builder(context)
@@ -244,6 +244,7 @@ object LocalPreferences {
accountCache.clear() accountCache.clear()
warmAccountCache(context) warmAccountCache(context)
context.settings = loadSettingsFromEncryptedStorage(context) context.settings = loadSettingsFromEncryptedStorage(context)
context.isSettingsLoaded = true
context.settings.language?.let { context.settings.language?.let {
AppCompatDelegate.setApplicationLocales( AppCompatDelegate.setApplicationLocales(
LocaleListCompat.forLanguageTags(it), LocaleListCompat.forLanguageTags(it),
@@ -251,6 +252,29 @@ object LocalPreferences {
} }
} }
/**
* Synchronously presets the OkHttp proxy from plain (unencrypted) prefs, before any
* dial can happen. The full settings load is async; until it lands, the relay/Coil
* factories treat torMode as unknown and route through the proxy client — which only
* protects Tor users if that client already carries the SOCKS proxy (fail-closed
* placeholder for BUILTIN, the configured port for ORBOT).
*/
fun presetProxyFromPrefs(context: Context) {
val prefs = sharedPrefs(context)
val torMode = try {
TorMode.valueOf(prefs.getString(SettingsKeys.TOR_MODE.key, TorMode.DISABLED.name)!!)
} catch (_: IllegalArgumentException) {
TorMode.DISABLED
}
when (torMode) {
TorMode.ORBOT -> HttpClientManager.setDefaultProxyOnPort(
prefs.getInt(SettingsKeys.PROXY_PORT.key, 9050),
)
TorMode.BUILTIN -> HttpClientManager.setDefaultProxyOnPort(TorManager.socksPort.value)
TorMode.DISABLED -> {}
}
}
fun getStartServiceOnBoot(context: Context): Boolean = sharedPrefs(context).getBoolean(SettingsKeys.START_SERVICE_ON_BOOT.key, true) fun getStartServiceOnBoot(context: Context): Boolean = sharedPrefs(context).getBoolean(SettingsKeys.START_SERVICE_ON_BOOT.key, true)
fun loadSettingsFromEncryptedStorage(context: Context = Amber.instance): AmberSettings { fun loadSettingsFromEncryptedStorage(context: Context = Amber.instance): AmberSettings {