Skip to content

Commit 2ec76d5

Browse files
Merge pull request #3199 from mvanhorn/fix/3197-vpn-control-intent-identity-fallback
fix: restore external intent control when sender identity is absent
2 parents 3dc18ac + 51f12c1 commit 2ec76d5

2 files changed

Lines changed: 79 additions & 23 deletions

File tree

‎app/src/main/java/com/celzero/bravedns/receiver/VPNControlReceiver.kt‎

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -44,16 +44,17 @@ import org.koin.core.component.inject
4444
* - [ACTION_WG_RESUME] - resume live wireguard proxy(es)
4545
*
4646
* Caller identity:
47-
* - Android 14 (API 34) and above: resolved exclusively from the OS-provided
48-
* [BroadcastReceiver.getSentFromPackage] / [BroadcastReceiver.getSentFromUid]; intent extras
49-
* are never trusted, even when the OS identity is absent.
47+
* - Android 14 (API 34) and above: prefers the OS-provided
48+
* [BroadcastReceiver.getSentFromPackage] / [BroadcastReceiver.getSentFromUid]. When the OS
49+
* identity is unavailable, [EXTRA_SENDER] is used as a compatibility fallback. That extra is
50+
* sender-controlled and spoofable, so it is accepted only when it is allowlisted below.
5051
* - Below Android 14: no OS-provided broadcast sender identity exists, so the legacy
51-
* [EXTRA_SENDER] extra convention is honored instead. NOTE: that extra is sender-controlled
52-
* and therefore spoofable on this path; it is kept solely to preserve automation support on
53-
* older Android versions.
52+
* [EXTRA_SENDER] extra convention is honored instead. That extra is sender-controlled and
53+
* therefore spoofable on this path; it is kept solely to preserve automation support on older
54+
* Android versions.
5455
*
5556
* Extras:
56-
* - [EXTRA_SENDER] (String, legacy, only honored below Android 14): calling package name
57+
* - [EXTRA_SENDER] (String, legacy or Android 14+ fallback): calling package name
5758
* - [EXTRA_WG_IDS] (String, required for the WG_* actions): comma separated wireguard config
5859
* ids, e.g. "1,3"
5960
*
@@ -66,7 +67,7 @@ class VpnControlReceiver: BroadcastReceiver(), KoinComponent {
6667
// seam for tests; executes validated wg commands
6768
internal var wgCommands: WgCommands = WgCommands()
6869

69-
// seam for tests; resolves the caller identity (never from untrusted extras on API 34+)
70+
// seam for tests; resolves the caller identity, preferring the OS-provided identity
7071
internal var callerResolver: (Context, Intent) -> String? =
7172
{ context, intent -> defaultCallerResolver(context, intent) }
7273

@@ -214,26 +215,36 @@ class VpnControlReceiver: BroadcastReceiver(), KoinComponent {
214215
internal fun defaultCallerResolver(context: Context, intent: Intent): String? {
215216
if (DEBUG) dumpIntent(intent)
216217

217-
// Android 14 (API 34) and above: the OS stamps the sender identity on every broadcast;
218-
// only trust that, never sender-controlled extras
218+
val senderExtra = intent.getStringExtra(EXTRA_SENDER)
219+
220+
// Android 14 (API 34) and above: prefer the OS-provided sender identity. Some external
221+
// automation broadcasts do not carry it, so retain the sender extra as a compatibility
222+
// fallback for the allowlist check in onReceive (the extra is sender-controlled).
219223
if (isAtleastU()) {
220224
val pkg = sentFromPackage
221225
?: context.packageManager.getPackagesForUid(sentFromUid)?.firstOrNull()
222226
if (pkg != null) {
223227
Logger.i(LOG_TAG_VPN, "$TAG caller from OS-provided identity: $pkg")
224228
return pkg
225229
}
226-
Logger.w(LOG_TAG_VPN, "$TAG no OS-provided sender identity, rejecting intent")
227-
return null
230+
if (senderExtra != null) {
231+
Logger.w(
232+
LOG_TAG_VPN,
233+
"$TAG no OS-provided sender identity, using sender extra fallback " +
234+
"(sender-controlled and spoofable): $senderExtra"
235+
)
236+
} else {
237+
Logger.w(LOG_TAG_VPN, "$TAG no OS-provided sender identity or sender extra")
238+
}
239+
return senderExtra
228240
}
229241

230242
// below Android 14 there is no OS-provided broadcast sender identity; fall back to the
231243
// legacy "sender" extra convention (spoofable on this path, accepted for automation)
232-
val legacy = intent.getStringExtra(EXTRA_SENDER)
233-
if (legacy != null) {
234-
Logger.i(LOG_TAG_VPN, "$TAG caller from legacy extra: $legacy")
244+
if (senderExtra != null) {
245+
Logger.i(LOG_TAG_VPN, "$TAG caller from legacy extra: $senderExtra")
235246
}
236-
return legacy
247+
return senderExtra
237248
}
238249

239250
fun dumpIntent(intent: Intent) {

‎app/src/test/java/com/celzero/bravedns/receiver/VpnControlReceiverTest.kt‎

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import com.celzero.bravedns.service.PersistentState
66
import io.mockk.coVerify
77
import io.mockk.every
88
import io.mockk.mockk
9+
import io.mockk.spyk
910
import kotlinx.coroutines.CoroutineScope
1011
import kotlinx.coroutines.Dispatchers
1112
import org.junit.Assert.assertEquals
@@ -200,28 +201,72 @@ class VpnControlReceiverTest {
200201
// ── caller identity resolution ───────────────────────────────────────────
201202

202203
@Test
203-
fun `default resolver ignores spoofed sender extra and rejects when os identity is absent`() {
204+
@Config(sdk = [34])
205+
fun `default resolver uses allowlisted sender extra when os identity is absent`() {
204206
every { persistentState.appTriggerPackages } returns "com.trusted.app"
205207
every { persistentState.wgTaskerAutomationEnabled } returns true
206208
receiver.callerResolver = { c, i -> receiver.defaultCallerResolver(c, i) }
207209
val intent = Intent(VpnControlReceiver.ACTION_WG_PAUSE).apply {
208-
// spoofed extra naming an allowlisted package must not grant trust
209210
putExtra(VpnControlReceiver.EXTRA_SENDER, "com.trusted.app")
210211
putExtra(VpnControlReceiver.EXTRA_WG_IDS, "1")
211212
}
212213
receiver.onReceive(context, intent)
214+
coVerify(exactly = 1) { wgCommands.pause(listOf(1)) }
215+
}
216+
217+
@Test
218+
@Config(sdk = [34])
219+
fun `default resolver rejects missing malformed and untrusted sender extras`() {
220+
every { persistentState.appTriggerPackages } returns "com.trusted.app"
221+
every { persistentState.wgTaskerAutomationEnabled } returns true
222+
receiver.callerResolver = { c, i -> receiver.defaultCallerResolver(c, i) }
223+
224+
listOf<String?>(null, "", "com.untrusted.app").forEach { sender ->
225+
val intent = Intent(VpnControlReceiver.ACTION_WG_PAUSE).apply {
226+
if (sender != null) {
227+
putExtra(VpnControlReceiver.EXTRA_SENDER, sender)
228+
}
229+
putExtra(VpnControlReceiver.EXTRA_WG_IDS, "1")
230+
}
231+
receiver.onReceive(context, intent)
232+
}
233+
213234
coVerify(exactly = 0) { wgCommands.pause(any()) }
214235
}
215236

216237
@Test
217-
fun `default resolver returns null on spoofed extra when no os identity is stamped`() {
238+
@Config(sdk = [34])
239+
fun `default resolver gives os package identity precedence over sender extra`() {
240+
val osIdentityReceiver = spyk(receiver)
241+
osIdentityReceiver.wgCommands = wgCommands
242+
osIdentityReceiver.callerResolver = { c, i ->
243+
osIdentityReceiver.defaultCallerResolver(c, i)
244+
}
245+
every { osIdentityReceiver.sentFromPackage } returns "com.os.app"
246+
every { persistentState.appTriggerPackages } returns "com.trusted.app"
247+
every { persistentState.wgTaskerAutomationEnabled } returns true
248+
val intent = Intent(VpnControlReceiver.ACTION_WG_PAUSE).apply {
249+
putExtra(VpnControlReceiver.EXTRA_SENDER, "com.trusted.app")
250+
putExtra(VpnControlReceiver.EXTRA_WG_IDS, "1")
251+
}
252+
253+
osIdentityReceiver.onReceive(context, intent)
254+
255+
coVerify(exactly = 0) { wgCommands.pause(any()) }
256+
}
257+
258+
@Test
259+
@Config(sdk = [34])
260+
fun `default resolver uses package resolved from os uid before sender extra`() {
261+
val osIdentityReceiver = spyk(receiver)
262+
every { osIdentityReceiver.sentFromPackage } returns null
263+
every { osIdentityReceiver.sentFromUid } returns 1234
264+
every { context.packageManager.getPackagesForUid(1234) } returns arrayOf("com.os.app")
218265
val intent = Intent(VpnControlReceiver.ACTION_STOP).apply {
219266
putExtra(VpnControlReceiver.EXTRA_SENDER, "com.trusted.app")
220267
}
221-
// on the Robolectric SDK no OS sender identity is stamped; the spoofed extra must not
222-
// be honored even though it names an allowlisted package
223-
val resolved = receiver.defaultCallerResolver(context, intent)
224-
assertEquals(null, resolved)
268+
269+
assertEquals("com.os.app", osIdentityReceiver.defaultCallerResolver(context, intent))
225270
}
226271

227272
@Test

0 commit comments

Comments
 (0)