Merge pull request #8970 from eed3si9n/bport/cve-fix

[2.0.x] bport: Harden Windows VCS URI fragments against command injection
This commit is contained in:
eugene yokota 2026-03-23 23:59:52 -04:00 committed by GitHub
commit 3d224539a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 212 additions and 10 deletions

View File

@ -19,9 +19,10 @@ import BuildLoader.ResolveInfo
import RichURI.fromURI
import java.util.Locale
import scala.sys.process.Process
import scala.sys.process.{ BasicIO, Process }
import scala.util.control.NonFatal
import sbt.internal.util.Util
import sbt.util.Logger
import sbt.internal.VcsUriFragment
object Resolvers {
type Resolver = BuildLoader.Resolver
@ -56,6 +57,7 @@ object Resolvers {
if (uri.hasFragment) {
val revision = uri.getFragment
VcsUriFragment.validate(revision)
Some { () =>
creates(localCopy) {
run("svn", "checkout", "-q", "-r", revision, from, to)
@ -88,6 +90,7 @@ object Resolvers {
if (uri.hasFragment) {
val branch = uri.getFragment
VcsUriFragment.validate(branch)
Some { () =>
creates(localCopy) {
run("git", "clone", from, localCopy.getAbsolutePath)
@ -116,6 +119,7 @@ object Resolvers {
if (uri.hasFragment) {
val branch = uri.getFragment
VcsUriFragment.validate(branch)
Some { () =>
creates(localCopy) {
clone(from, to = localCopy)
@ -134,14 +138,20 @@ object Resolvers {
def run(command: String*): Unit =
run(None, command*)
def run(cwd: Option[File], command: String*): Unit = {
val result = Process(
if (Util.isNonCygwinWindows) "cmd" +: "/c" +: command
else command,
cwd
).!
if (result != 0)
sys.error("Nonzero exit code (" + result + "): " + command.mkString(" "))
def run(cwd: Option[File], command: String*): Unit =
run(None, None, command*)
private def run(cwd: Option[File], log: Option[Logger], command: String*): Unit = {
val process = Process(command, cwd)
val result = (log match {
case Some(log) =>
val io = BasicIO(false, log).withInput(_.close())
process.run(io).exitValue()
case None =>
process.run().exitValue()
})
if result != 0 then sys.error("Nonzero exit code (" + result + "): " + command.mkString(" "))
}
def creates(file: File)(f: => Unit) = {

View File

@ -0,0 +1,32 @@
/*
* sbt
* Copyright 2023, Scala center
* Copyright 2011 - 2022, Lightbend, Inc.
* Copyright 2008 - 2010, Mark Harrah
* Licensed under Apache License 2.0 (see LICENSE)
*/
package sbt
package internal
private[sbt] object VcsUriFragment {
def validate(fragment: String): Unit = {
if (fragment == null)
throw new IllegalArgumentException("VCS URI fragment must not be null")
if (fragment.isEmpty)
throw new IllegalArgumentException("VCS URI fragment must not be empty")
fragment.foreach { c =>
if (!isAllowed(c))
throw new IllegalArgumentException(
"Invalid character in VCS URI fragment (only ASCII letters, digits, and - _ . / + are allowed)"
)
}
}
private def isAllowed(c: Char): Boolean =
(c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '-' || c == '_' || c == '.' || c == '/' || c == '+'
}

View File

@ -0,0 +1,86 @@
/*
* sbt
* Copyright 2023, Scala center
* Copyright 2011 - 2022, Lightbend, Inc.
* Copyright 2008 - 2010, Mark Harrah
* Licensed under Apache License 2.0 (see LICENSE)
*/
package sbt
package internal
import hedgehog.*
import hedgehog.runner.*
import java.net.URI
import scala.jdk.CollectionConverters.*
import _root_.sbt.Resolvers
import _root_.sbt.io.IO
import _root_.sbt.internal.BuildLoader.ResolveInfo
object ResolversVcsSecurityTest extends Properties:
override def tests: List[Test] = List(
example(
"git resolver rejects fragment containing & before running VCS",
testResolverRejects(Resolvers.git, vcsUri("git", "/repo.git", "main&evil"))
),
example(
"git resolver rejects fragment containing |",
testResolverRejects(Resolvers.git, vcsUri("git", "/repo.git", "main|evil"))
),
example(
"git resolver rejects fragment containing ;",
testResolverRejects(Resolvers.git, vcsUri("git", "/repo.git", "main;evil"))
),
example(
"mercurial resolver rejects fragment containing & before running VCS",
testResolverRejects(Resolvers.mercurial, vcsUri("hg", "/repo", "main&evil"))
),
example(
"subversion resolver rejects fragment containing & before running VCS",
testResolverRejects(Resolvers.subversion, vcsUri("svn", "/repo", "main&evil"))
),
example(
"git resolver accepts safe branch fragment and returns Some",
testResolverAccepts(Resolvers.git, vcsUri("git", "/repo.git", "develop"))
),
example(
"ProcessBuilder passes VCS ref as a single argv element (no shell parsing)",
testProcessBuilderKeepsMetacharactersInSingleArgument
),
)
private def vcsUri(scheme: String, path: String, fragment: String): URI =
new URI(scheme, null, "example.com", -1, path, null, fragment)
private def testResolverRejects(resolver: Resolvers.Resolver, uri: URI): Result =
val staging = IO.createTemporaryDirectory
try
val info = new ResolveInfo(uri, staging, null, null)
try
resolver(info)
Result.failure.log(s"expected IllegalArgumentException for $uri")
catch case _: IllegalArgumentException => Result.success
finally IO.delete(staging)
private def testResolverAccepts(resolver: Resolvers.Resolver, uri: URI): Result =
val staging = IO.createTemporaryDirectory
try
val info = new ResolveInfo(uri, staging, null, null)
try
resolver(info) match
case Some(_) => Result.success
case None => Result.failure.log(s"expected Some for $uri")
catch
case e: IllegalArgumentException =>
Result.failure.log(s"unexpected IllegalArgumentException for $uri: ${e.getMessage}")
finally IO.delete(staging)
private def testProcessBuilderKeepsMetacharactersInSingleArgument: Result =
val argv =
new ProcessBuilder("git", "fetch", "origin", "topic&injection").command().asScala.toList
Result.assert(argv == List("git", "fetch", "origin", "topic&injection"))
end ResolversVcsSecurityTest

View File

@ -0,0 +1,74 @@
/*
* sbt
* Copyright 2023, Scala center
* Copyright 2011 - 2022, Lightbend, Inc.
* Copyright 2008 - 2010, Mark Harrah
* Licensed under Apache License 2.0 (see LICENSE)
*/
package sbt
package internal
import hedgehog.*
import hedgehog.runner.*
object VcsUriFragmentTest extends Properties:
override def tests: List[Test] = List(
example("accepts typical branch and tag names", testAcceptsSafe),
example("accepts hex commit id fragment", testAcceptsHexSha),
example("rejects empty fragment", testRejectsEmpty),
example("rejects ampersand", testRejectsAmpersand),
example("rejects pipe", testRejectsPipe),
example("rejects semicolon", testRejectsSemicolon),
example("rejects space", testRejectsSpace),
example("rejects percent", testRejectsPercent),
example("rejects greater-than", testRejectsGreaterThan),
example("rejects newline", testRejectsNewline),
example("rejects DEL", testRejectsDel),
)
def testAcceptsSafe: Result =
VcsUriFragment.validate("develop")
VcsUriFragment.validate("v1.2.3")
VcsUriFragment.validate("feature/foo-bar")
VcsUriFragment.validate("release/1.0.0+build")
Result.success
def testAcceptsHexSha: Result =
VcsUriFragment.validate("abc123def4567890abcdef1234567890abcdef12")
Result.success
def testRejectsEmpty: Result =
interceptIllegal("")
def testRejectsAmpersand: Result =
interceptIllegal("a&b")
def testRejectsPipe: Result =
interceptIllegal("a|b")
def testRejectsSemicolon: Result =
interceptIllegal("a;b")
def testRejectsSpace: Result =
interceptIllegal("a b")
def testRejectsPercent: Result =
interceptIllegal("a%20b")
def testRejectsGreaterThan: Result =
interceptIllegal("a>b")
def testRejectsNewline: Result =
interceptIllegal("a\nb")
def testRejectsDel: Result =
interceptIllegal("a\u007fb")
private def interceptIllegal(s: String): Result =
try
VcsUriFragment.validate(s)
Result.failure.log(s"expected failure for ${s.map(_.toInt).mkString(",")}")
catch case _: IllegalArgumentException => Result.success
end VcsUriFragmentTest