From 57097ae7d90da823e0a507194d6ba26e71542cf4 Mon Sep 17 00:00:00 2001 From: Eugene Yokota Date: Tue, 4 Aug 2026 18:01:23 -0400 Subject: [PATCH 1/2] [1.x] fix: Gate LSP calls behind auth **Problem** Some custom LSP calls do not check initialize-handshake, which over TCP includes token-based authentication. **Solution** This adds checkAuthenticated check around sbt/exec etc. --- build.sbt | 1 + .../sbt/internal/server/ServerHandler.scala | 1 + .../main/scala/sbt/internal/LintUnused.scala | 1 + .../server/LanguageServerProtocol.scala | 49 ++++++++++++++----- .../sbt/internal/server/NetworkChannel.scala | 11 ++++- .../sbt/internal/server/VirtualTerminal.scala | 36 +++++++++----- server-test/src/server-test/tcp/build.sbt | 9 ++++ 7 files changed, 81 insertions(+), 27 deletions(-) create mode 100644 server-test/src/server-test/tcp/build.sbt diff --git a/build.sbt b/build.sbt index 261dca61d..69ca3b218 100644 --- a/build.sbt +++ b/build.sbt @@ -786,6 +786,7 @@ lazy val commandProj = (project in file("main-command")) Compile / generateContrabands / contrabandFormatsForType := ContrabandConfig.getFormats, mimaSettings, mimaBinaryIssueFilters ++= Vector( + exclude[ReversedMissingMethodProblem]("sbt.internal.server.ServerCallback.*"), exclude[DirectMissingMethodProblem]("sbt.Exit.apply"), exclude[DirectMissingMethodProblem]("sbt.Reboot.apply"), exclude[DirectMissingMethodProblem]("sbt.TemplateResolverInfo.apply"), diff --git a/main-command/src/main/scala/sbt/internal/server/ServerHandler.scala b/main-command/src/main/scala/sbt/internal/server/ServerHandler.scala index 9a20f8253..875d630f3 100644 --- a/main-command/src/main/scala/sbt/internal/server/ServerHandler.scala +++ b/main-command/src/main/scala/sbt/internal/server/ServerHandler.scala @@ -78,6 +78,7 @@ trait ServerCallback { private[sbt] def authOptions: Set[ServerAuthentication] private[sbt] def authenticate(token: String): Boolean + private[sbt] def isAuthenticated: Boolean private[sbt] def setInitialized(value: Boolean): Unit private[sbt] def setInitializeOption(opts: InitializeOption): Unit private[sbt] def onSettingQuery(execId: Option[String], req: Q): Unit diff --git a/main/src/main/scala/sbt/internal/LintUnused.scala b/main/src/main/scala/sbt/internal/LintUnused.scala index 7846d41f0..0a4d25bae 100644 --- a/main/src/main/scala/sbt/internal/LintUnused.scala +++ b/main/src/main/scala/sbt/internal/LintUnused.scala @@ -43,6 +43,7 @@ object LintUnused { onUnload, sbt.nio.Keys.watchTriggers, serverConnectionType, + serverPort, serverIdleTimeout, shellPrompt, sonaDeploymentName, diff --git a/main/src/main/scala/sbt/internal/server/LanguageServerProtocol.scala b/main/src/main/scala/sbt/internal/server/LanguageServerProtocol.scala index 5ea191812..2b15c8ad8 100644 --- a/main/src/main/scala/sbt/internal/server/LanguageServerProtocol.scala +++ b/main/src/main/scala/sbt/internal/server/LanguageServerProtocol.scala @@ -45,6 +45,16 @@ private[sbt] object LanguageServerProtocol { def handler(converter: FileConverter): ServerHandler = ServerHandler { callback => import callback._ + + def checkAuthenticated(r: JsonRpcRequestMessage)(f: => Unit): Unit = + if (!isAuthenticated) + jsonRpcRespondError( + Some(r.id), + ErrorCodes.InvalidRequest, + s"'${r.method}' is not allowed before authentication." + ) + else f + ServerIntent( onRequest = { case r: JsonRpcRequestMessage if r.method == "initialize" => @@ -67,32 +77,45 @@ private[sbt] object LanguageServerProtocol { jsonRpcRespond(InitializeResult(serverCapabilities), Some(r.id)) case r: JsonRpcRequestMessage if r.method == "textDocument/definition" => - val _ = Definition.lspDefinition(json(r), r.id, CommandSource(name), converter, log)( - StandardMain.executionContext - ) + checkAuthenticated(r) { + val _ = + Definition.lspDefinition(json(r), r.id, CommandSource(name), converter, log)( + StandardMain.executionContext + ) + } case r: JsonRpcRequestMessage if r.method == "sbt/exec" => - val param = Converter.fromJson[SbtExecParams](json(r)).get - val _ = appendExec(param.commandLine, Some(r.id)) + checkAuthenticated(r) { + val param = Converter.fromJson[SbtExecParams](json(r)).get + val _ = appendExec(param.commandLine, Some(r.id)) + } case r: JsonRpcRequestMessage if r.method == "sbt/setting" => - val param = Converter.fromJson[Q](json(r)).get - onSettingQuery(Option(r.id), param) + checkAuthenticated(r) { + val param = Converter.fromJson[Q](json(r)).get + onSettingQuery(Option(r.id), param) + } case r: JsonRpcRequestMessage if r.method == "sbt/cancelRequest" => - val param = Converter.fromJson[CancelRequestParams](json(r)).get - onCancellationRequest(Option(r.id), param) + checkAuthenticated(r) { + val param = Converter.fromJson[CancelRequestParams](json(r)).get + onCancellationRequest(Option(r.id), param) + } case r: JsonRpcRequestMessage if r.method == "sbt/completion" => - import sbt.protocol.codec.JsonProtocol._ - val param = Converter.fromJson[CP](json(r)).get - onCompletionRequest(Option(r.id), param) + checkAuthenticated(r) { + import sbt.protocol.codec.JsonProtocol._ + val param = Converter.fromJson[CP](json(r)).get + onCompletionRequest(Option(r.id), param) + } }, onResponse = PartialFunction.empty, onNotification = { case n: JsonRpcNotificationMessage if n.method == "textDocument/didSave" => - val _ = appendExec(";Test/compile; collectAnalyses", None) + if (isAuthenticated) { + val _ = appendExec(";Test/compile; collectAnalyses", None) + } else log.warn(s"ignoring '${n.method}' before authentication") } ) } diff --git a/main/src/main/scala/sbt/internal/server/NetworkChannel.scala b/main/src/main/scala/sbt/internal/server/NetworkChannel.scala index 36ace91f1..95d351aee 100644 --- a/main/src/main/scala/sbt/internal/server/NetworkChannel.scala +++ b/main/src/main/scala/sbt/internal/server/NetworkChannel.scala @@ -84,6 +84,7 @@ final class NetworkChannel( private val delimiter: Byte = '\n'.toByte private val out = connection.getOutputStream private var initialized = false + private var authenticated = false /** Reference to the client-side custom options */ @@ -137,6 +138,7 @@ final class NetworkChannel( def name: String = self.name private[sbt] def authOptions: Set[ServerAuthentication] = self.authOptions private[sbt] def authenticate(token: String): Boolean = self.authenticate(token) + private[sbt] def isAuthenticated: Boolean = self.isAuthenticated private[sbt] def setInitialized(value: Boolean): Unit = self.setInitialized(value) private[sbt] def setInitializeOption(opts: InitializeOption): Unit = self.setInitializeOption(opts) @@ -157,7 +159,14 @@ final class NetworkChannel( case _ => false } - protected def authenticate(token: String): Boolean = instance.authenticate(token) + protected def authenticate(token: String): Boolean = { + val result = instance.authenticate(token) + if (result) authenticated = true + result + } + + private[sbt] def isAuthenticated: Boolean = + authenticated || authOptions.isEmpty protected def setInitialized(value: Boolean): Unit = initialized = value diff --git a/main/src/main/scala/sbt/internal/server/VirtualTerminal.scala b/main/src/main/scala/sbt/internal/server/VirtualTerminal.scala index 165202287..8948ff2ea 100644 --- a/main/src/main/scala/sbt/internal/server/VirtualTerminal.scala +++ b/main/src/main/scala/sbt/internal/server/VirtualTerminal.scala @@ -12,6 +12,7 @@ package server import java.util.concurrent.{ ArrayBlockingQueue, ConcurrentHashMap } import java.util.UUID +import sbt.internal.langserver.ErrorCodes import sbt.internal.protocol.{ JsonRpcNotificationMessage, JsonRpcRequestMessage, @@ -177,13 +178,20 @@ object VirtualTerminal { private val requestHandler: Handler[JsonRpcRequestMessage] = callback => { case r if r.method == attach => - val isInteractive = r.params - .flatMap(Converter.fromJson[Attach](_).toOption.map(_.interactive)) - .exists(identity) - StandardMain.exchange.channelForName(callback.name) match { - case Some(nc: NetworkChannel) => nc.setInteractive(r.id, isInteractive) - case _ => - } + if (callback.isAuthenticated) { + val isInteractive = r.params + .flatMap(Converter.fromJson[Attach](_).toOption.map(_.interactive)) + .exists(identity) + StandardMain.exchange.channelForName(callback.name) match { + case Some(nc: NetworkChannel) => nc.setInteractive(r.id, isInteractive) + case _ => + } + } else + callback.jsonRpcRespondError( + Some(r.id), + ErrorCodes.InvalidRequest, + s"'$attach' is not allowed before authentication." + ) } private val responseHandler: Handler[JsonRpcResponseMessage] = callback => { @@ -243,12 +251,14 @@ object VirtualTerminal { private val notificationHandler: Handler[JsonRpcNotificationMessage] = callback => { case n if n.method == systemIn => - import sjsonnew.BasicJsonProtocol._ - n.params.flatMap(Converter.fromJson[Byte](_).toOption).foreach { byte => - StandardMain.exchange.channelForName(callback.name) match { - case Some(nc: NetworkChannel) => nc.write(byte) - case _ => + if (callback.isAuthenticated) { + import sjsonnew.BasicJsonProtocol._ + n.params.flatMap(Converter.fromJson[Byte](_).toOption).foreach { byte => + StandardMain.exchange.channelForName(callback.name) match { + case Some(nc: NetworkChannel) => nc.write(byte) + case _ => + } } - } + } else callback.log.warn(s"ignoring '$systemIn' before authentication") } } diff --git a/server-test/src/server-test/tcp/build.sbt b/server-test/src/server-test/tcp/build.sbt new file mode 100644 index 000000000..067d9d87c --- /dev/null +++ b/server-test/src/server-test/tcp/build.sbt @@ -0,0 +1,9 @@ +scalaVersion := "3.8.4" + +Global / serverConnectionType := ConnectionType.Tcp +Global / serverPort := 5002 + +lazy val root = (project in file(".")) + .settings( + name := "tcp", + ) From e114567acb47f9414a39296f81610e6cb5ea6121 Mon Sep 17 00:00:00 2001 From: Eugene Yokota Date: Tue, 4 Aug 2026 18:19:36 -0400 Subject: [PATCH 2/2] [1.x] Retire textDocument/definition --- .../sbt/internal/server/LanguageServerProtocol.scala | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/main/src/main/scala/sbt/internal/server/LanguageServerProtocol.scala b/main/src/main/scala/sbt/internal/server/LanguageServerProtocol.scala index 2b15c8ad8..df70b9cd1 100644 --- a/main/src/main/scala/sbt/internal/server/LanguageServerProtocol.scala +++ b/main/src/main/scala/sbt/internal/server/LanguageServerProtocol.scala @@ -39,7 +39,7 @@ private[sbt] object LanguageServerProtocol { ServerCapabilities( textDocumentSync = TextDocumentSyncOptions(true, 0, false, false, SaveOptions(false)), hoverProvider = false, - definitionProvider = true + definitionProvider = false ) } @@ -76,14 +76,6 @@ private[sbt] object LanguageServerProtocol { if (!opt.skipAnalysis.getOrElse(false)) appendExec("collectAnalyses", None) jsonRpcRespond(InitializeResult(serverCapabilities), Some(r.id)) - case r: JsonRpcRequestMessage if r.method == "textDocument/definition" => - checkAuthenticated(r) { - val _ = - Definition.lspDefinition(json(r), r.id, CommandSource(name), converter, log)( - StandardMain.executionContext - ) - } - case r: JsonRpcRequestMessage if r.method == "sbt/exec" => checkAuthenticated(r) { val param = Converter.fromJson[SbtExecParams](json(r)).get