From 0139bcff5cc66324fba0e8a466b0deef3f091611 Mon Sep 17 00:00:00 2001 From: Antriksh Date: Mon, 14 Sep 2026 14:25:56 +0530 Subject: [PATCH] fix(kernel): require verified caller identity for the active-tabs bridge Every ActiveTabsService RPC was reachable by any process able to open a connection to the kernel IPC server, not only the plugins the host itself loaded. watchActiveTabs disclosed every open tab's title and URL across every window with no credential at all, and createBrowserTab could force the browser to navigate to an arbitrary URL; selectTab/closeTab could manipulate what the user is looking at. All seven RPCs now require a verified caller identity, matching the pattern PR #505 established for the Secret Service: unary RPCs call authenticatedCallerOrRefuse, and the watchActiveTabs stream captures ProcessIdentityInterceptor.CURRENT_IDENTITY synchronously in the method body (before the flow is collected, on the calling thread the interceptor's context is current on) and re-checks it before every emission, so a revoked caller's stream is refused before an already-held handle could receive a value it should no longer see. Refusals are logged with a bind/revocation stage tag, and the three mutating RPCs (selectTab, createBrowserTab, closeTab) now record an attributed audit line - caller and RPC name only, no URLs or titles. Tested with a real gRPC server plus ProcessIdentityInterceptor: every RPC is refused with no credential and never reaches the provider, an authenticated caller can watch/read/select/create/close tabs, and a revoked caller's live watch stream terminates with PERMISSION_DENIED before receiving a value that arrived after revocation. --- .../services/ActiveTabsServiceBridge.kt | 76 +++++- .../services/ActiveTabsServiceBridgeTest.kt | 257 ++++++++++++++++++ 2 files changed, 331 insertions(+), 2 deletions(-) create mode 100644 composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/ActiveTabsServiceBridgeTest.kt diff --git a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/ActiveTabsServiceBridge.kt b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/ActiveTabsServiceBridge.kt index dea02a84df..47679dbcf1 100644 --- a/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/ActiveTabsServiceBridge.kt +++ b/composeApp/src/desktopMain/kotlin/ai/rever/boss/kernel/services/ActiveTabsServiceBridge.kt @@ -1,17 +1,45 @@ package ai.rever.boss.kernel.services +import ai.rever.boss.ipc.auth.ProcessIdentityInterceptor import ai.rever.boss.ipc.proto.Empty import ai.rever.boss.ipc.proto.services.* import ai.rever.boss.plugin.api.ActiveTabsProvider +import ai.rever.boss.plugin.logging.BossLogger +import ai.rever.boss.plugin.logging.LogCategory +import io.grpc.Status +import io.grpc.StatusException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow +/** + * Kernel-side bridge for `ActiveTabsService`. + * + * Revocation is checked before each emission; idle streams are not proactively disconnected. + * + * **Every call requires a verified caller identity (BossConsole#53)**, the same requirement and + * helper shape introduced for the Secret Service in PR #505. Before this bridge checked identity + * at all, any process able to open a connection to the kernel IPC server - not only the plugins + * the host itself loaded - could [watchActiveTabs] to see every open tab's title and URL across + * every window (browsing history and open documents, disclosed with no credential), and could + * [createBrowserTab] to force the browser to navigate to an arbitrary URL, or [closeTab]/ + * [selectTab] to manipulate what the user is looking at. + */ +// One method per RPC the generated service base class declares, plus small identity and audit helpers. +@Suppress("TooManyFunctions") class ActiveTabsServiceBridge( private val provider: ActiveTabsProvider, ) : ActiveTabsServiceGrpcKt.ActiveTabsServiceCoroutineImplBase() { - override fun watchActiveTabs(request: Empty): Flow = - flow { + override fun watchActiveTabs(request: Empty): Flow { + // Capture the gRPC context before returning the asynchronously collected flow. + val currentIdentity = ProcessIdentityInterceptor.CURRENT_IDENTITY.get() + return flow { + val caller = + currentIdentity?.invoke() + ?: refuseIdentity("watchActiveTabs") provider.activeTabs.collect { tabs -> + if (currentIdentity.invoke() != caller) { + refuseIdentity("watchActiveTabs", "revocation") + } emit( ActiveTabListResponse .newBuilder() @@ -35,18 +63,23 @@ class ActiveTabsServiceBridge( ) } } + } override suspend fun refreshTabs(request: Empty): Empty { + authenticatedCallerOrRefuse("refreshTabs") provider.refreshTabs() return Empty.getDefaultInstance() } override suspend fun selectTab(request: SelectTabRequest): Empty { + val caller = authenticatedCallerOrRefuse("selectTab") + logMutation("selectTab", caller) provider.selectTab(request.tabId, request.panelId) return Empty.getDefaultInstance() } override suspend fun getTabUrl(request: TabIdRequest): ActiveTabStringResponse { + authenticatedCallerOrRefuse("getTabUrl") val url = provider.getTabUrl(request.tabId) return ActiveTabStringResponse .newBuilder() @@ -55,6 +88,7 @@ class ActiveTabsServiceBridge( } override suspend fun getFaviconCacheKey(request: TabIdRequest): ActiveTabStringResponse { + authenticatedCallerOrRefuse("getFaviconCacheKey") val key = provider.getFaviconCacheKey(request.tabId) return ActiveTabStringResponse .newBuilder() @@ -63,6 +97,8 @@ class ActiveTabsServiceBridge( } override suspend fun createBrowserTab(request: CreateBrowserTabRequest): ActiveTabStringResponse { + val caller = authenticatedCallerOrRefuse("createBrowserTab") + logMutation("createBrowserTab", caller) val tabId = provider.createBrowserTab(request.url, request.title) return ActiveTabStringResponse .newBuilder() @@ -71,10 +107,46 @@ class ActiveTabsServiceBridge( } override suspend fun closeTab(request: TabIdRequest): ActiveTabBoolResponse { + val caller = authenticatedCallerOrRefuse("closeTab") + logMutation("closeTab", caller) val success = provider.closeTab(request.tabId) return ActiveTabBoolResponse .newBuilder() .setValue(success) .build() } + + /** + * Unary RPCs only: the verified identity, or a thrown `PERMISSION_DENIED` when there is none. + * + * Mirrors the helper introduced by PR #505 (BossConsole#53) - fails closed rather than let a + * request with no credential fall through to [provider] with nothing to attribute it to. + */ + private fun authenticatedCallerOrRefuse(rpc: String): String = + ProcessIdentityInterceptor.AUTHENTICATED_PROCESS_ID.get() ?: refuseIdentity(rpc) + + private fun logMutation( + rpc: String, + caller: String, + ) { + logger.info(LogCategory.AUTH, "Authenticated tab mutation requested", mapOf("rpc" to rpc, "caller" to caller)) + } + + private fun refuseIdentity( + rpc: String, + stage: String = "bind", + ): Nothing { + logger.warn( + LogCategory.AUTH, + "Refused $rpc: no current verified process identity on this call", + mapOf("rpc" to rpc, "stage" to stage), + ) + throw StatusException(Status.PERMISSION_DENIED.withDescription(NO_IDENTITY)) + } + + private companion object { + val logger = BossLogger.forComponent("ActiveTabsServiceBridge") + + const val NO_IDENTITY = "This call presented no verified process identity" + } } diff --git a/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/ActiveTabsServiceBridgeTest.kt b/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/ActiveTabsServiceBridgeTest.kt new file mode 100644 index 0000000000..a9fe582f9a --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/ai/rever/boss/kernel/services/ActiveTabsServiceBridgeTest.kt @@ -0,0 +1,257 @@ +package ai.rever.boss.kernel.services + +import ai.rever.boss.ipc.auth.ProcessIdentityInterceptor +import ai.rever.boss.ipc.auth.ProcessTokenClientInterceptor +import ai.rever.boss.ipc.auth.ProcessTokenRegistry +import ai.rever.boss.ipc.proto.Empty +import ai.rever.boss.ipc.proto.services.ActiveTabsServiceGrpcKt +import ai.rever.boss.ipc.proto.services.CreateBrowserTabRequest +import ai.rever.boss.ipc.proto.services.SelectTabRequest +import ai.rever.boss.ipc.proto.services.TabIdRequest +import ai.rever.boss.plugin.api.ActiveTabData +import ai.rever.boss.plugin.api.ActiveTabsProvider +import ai.rever.boss.plugin.api.BrowserIntegration +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import io.grpc.ManagedChannel +import io.grpc.ManagedChannelBuilder +import io.grpc.Server +import io.grpc.ServerBuilder +import io.grpc.Status +import io.grpc.StatusException +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withTimeout +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * The active-tabs bridge, exercised over a real gRPC server (BossConsole#53). + * + * Before this bridge checked identity, any process able to open a connection to the kernel IPC + * server - not only the plugins the host itself loaded - could watch every open tab's title and + * URL across every window with no credential at all, and could force the browser to navigate to + * an arbitrary URL via `createBrowserTab`. + */ +class ActiveTabsServiceBridgeTest { + private lateinit var tokenRegistry: ProcessTokenRegistry + private lateinit var provider: FakeActiveTabsProvider + private lateinit var server: Server + private lateinit var authenticatedChannel: ManagedChannel + private lateinit var anonymousChannel: ManagedChannel + private lateinit var authenticated: ActiveTabsServiceGrpcKt.ActiveTabsServiceCoroutineStub + private lateinit var anonymous: ActiveTabsServiceGrpcKt.ActiveTabsServiceCoroutineStub + + @BeforeTest + fun setUp() { + tokenRegistry = ProcessTokenRegistry() + provider = FakeActiveTabsProvider() + server = + ServerBuilder + .forPort(0) + .intercept(ProcessIdentityInterceptor(tokenRegistry)) + .addService(ActiveTabsServiceBridge(provider)) + .build() + .start() + authenticatedChannel = + ManagedChannelBuilder + .forAddress("localhost", server.port) + .usePlaintext() + .intercept(ProcessTokenClientInterceptor(tokenRegistry.issue(CALLER))) + .build() + anonymousChannel = ManagedChannelBuilder.forAddress("localhost", server.port).usePlaintext().build() + authenticated = ActiveTabsServiceGrpcKt.ActiveTabsServiceCoroutineStub(authenticatedChannel) + anonymous = ActiveTabsServiceGrpcKt.ActiveTabsServiceCoroutineStub(anonymousChannel) + } + + @AfterTest + fun tearDown() { + authenticatedChannel.shutdownNow() + authenticatedChannel.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + anonymousChannel.shutdownNow() + anonymousChannel.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + server.shutdownNow() + server.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + } + + @Test + fun `every RPC is refused with no credential, and never reaches the tab provider`() = + runBlocking { + assertRefused { anonymous.watchActiveTabs(Empty.getDefaultInstance()).take(1).toList() } + assertRefused { anonymous.refreshTabs(Empty.getDefaultInstance()) } + assertRefused { anonymous.selectTab(SelectTabRequest.newBuilder().setTabId("t1").build()) } + assertRefused { anonymous.getTabUrl(TabIdRequest.newBuilder().setTabId("t1").build()) } + assertRefused { anonymous.getFaviconCacheKey(TabIdRequest.newBuilder().setTabId("t1").build()) } + assertRefused { + anonymous.createBrowserTab(CreateBrowserTabRequest.newBuilder().setUrl("https://evil.example").build()) + } + assertRefused { anonymous.closeTab(TabIdRequest.newBuilder().setTabId("t1").build()) } + + assertTrue(provider.calls.isEmpty(), "a refused call must never reach the tab provider") + } + + @Test + fun `an authenticated caller can watch and read tabs`() = + runBlocking { + provider.setTabs(listOf(tab("t1", url = "https://example.com"))) + + val response = + authenticated + .watchActiveTabs(Empty.getDefaultInstance()) + .take(1) + .toList() + .single() + assertEquals("https://example.com", response.tabsList.single().url) + + val url = authenticated.getTabUrl(TabIdRequest.newBuilder().setTabId("t1").build()) + assertEquals("https://example.com", url.value) + authenticated.refreshTabs(Empty.getDefaultInstance()) + val favicon = authenticated.getFaviconCacheKey(TabIdRequest.newBuilder().setTabId("t1").build()) + assertEquals("", favicon.value) + assertEquals(listOf("activeTabs", "getTabUrl", "refreshTabs", "getFaviconCacheKey"), provider.calls) + } + + @Test + fun `an authenticated caller can select, create and close tabs`() = + runBlocking { + authenticated.selectTab( + SelectTabRequest + .newBuilder() + .setTabId("t1") + .setPanelId("p1") + .build(), + ) + authenticated.createBrowserTab(CreateBrowserTabRequest.newBuilder().setUrl("https://example.com").build()) + authenticated.closeTab(TabIdRequest.newBuilder().setTabId("t1").build()) + + assertEquals(listOf("selectTab", "createBrowserTab", "closeTab"), provider.calls) + } + + @Test + fun `revoked caller cannot invoke a unary RPC`() = + runBlocking { + tokenRegistry.revoke(CALLER) + assertRefused { authenticated.refreshTabs(Empty.getDefaultInstance()) } + assertTrue(provider.calls.isEmpty()) + } + + @Test + fun `revoked watcher cannot receive a later snapshot`() = + runBlocking { + withTimeout(10_000) { + supervisorScope { + val received = Channel(Channel.UNLIMITED) + val watching = + async { + authenticated.watchActiveTabs(Empty.getDefaultInstance()).collect { received.send(Unit) } + } + try { + received.receive() + tokenRegistry.revoke(CALLER) + provider.setTabs(listOf(tab("after-revocation"))) + val failure = assertFailsWith { watching.await() } + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + assertTrue(received.tryReceive().isFailure, "revocation must prevent the next snapshot") + } finally { + watching.cancel() + received.close() + } + } + } + } + + private suspend fun assertRefused(call: suspend () -> Unit) { + val failure = assertFailsWith { call() } + assertEquals(Status.Code.PERMISSION_DENIED, failure.status.code) + } + + private fun tab( + tabId: String, + url: String? = null, + ): ActiveTabData = + ActiveTabData( + tabId = tabId, + typeId = "browser", + title = "Test tab", + workspaceId = "w1", + workspaceName = "Workspace", + panelId = "p1", + windowId = "win1", + splitPosition = null, + url = url, + faviconCacheKey = null, + ) + + /** Records every method it was actually asked to perform, so a refusal can be proven silent. */ + private class FakeActiveTabsProvider : ActiveTabsProvider { + val calls = mutableListOf() + private val _activeTabs = MutableStateFlow>(emptyList()) + override val activeTabs: StateFlow> + get() { + calls += "activeTabs" + return _activeTabs + } + + fun setTabs(tabs: List) { + _activeTabs.value = tabs + } + + override suspend fun refreshTabs() { + calls += "refreshTabs" + } + + override fun selectTab( + tabId: String, + panelId: String, + ) { + calls += "selectTab" + } + + override fun getTabUrl(tabId: String): String? { + calls += "getTabUrl" + return _activeTabs.value.find { it.tabId == tabId }?.url + } + + override fun getFaviconCacheKey(tabId: String): String? { + calls += "getFaviconCacheKey" + return _activeTabs.value.find { it.tabId == tabId }?.faviconCacheKey + } + + override fun createBrowserTab( + url: String, + title: String, + ): String? { + calls += "createBrowserTab" + return "new-tab-id" + } + + override fun closeTab(tabId: String): Boolean { + calls += "closeTab" + return true + } + + @Composable + override fun loadFavicon(cacheKey: String?): Painter? = null + + override fun getFallbackIcon(typeId: String): ImageVector? = null + + override fun getBrowserIntegration(tabId: String): BrowserIntegration? = null + } + + private companion object { + const val CALLER = "tabs-panel-plugin" + const val SHUTDOWN_TIMEOUT_MS = 5_000L + } +}