mirror of https://github.com/sbt/sbt.git
Merge commit from fork
* Harden Windows VCS URI fragments against command injection - Use Process(argv) for git/hg/svn without cmd /c on Windows - Add VcsUriFragment.validate for fragments in clone/checkout/update - Add tests * Allowlist-based approach to VCS string sanitization
This commit is contained in:
parent
baf455ecf6
commit
a9347cf220
|
|
@ -21,10 +21,9 @@ import java.util.Locale
|
||||||
|
|
||||||
import scala.sys.process.{ BasicIO, Process }
|
import scala.sys.process.{ BasicIO, Process }
|
||||||
import scala.util.control.NonFatal
|
import scala.util.control.NonFatal
|
||||||
import sbt.internal.util.Util
|
|
||||||
import sbt.internal.{ BuildDependencies, LoadedBuild, LoadedBuildUnit }
|
import sbt.internal.{ BuildDependencies, LoadedBuild, LoadedBuildUnit }
|
||||||
import sbt.util.Logger
|
import sbt.util.Logger
|
||||||
import sbt.internal.RetrieveUnit
|
import sbt.internal.{ RetrieveUnit, VcsUriFragment }
|
||||||
|
|
||||||
object Resolvers {
|
object Resolvers {
|
||||||
|
|
||||||
|
|
@ -67,6 +66,7 @@ object Resolvers {
|
||||||
|
|
||||||
if (uri.hasFragment) {
|
if (uri.hasFragment) {
|
||||||
val revision = uri.getFragment
|
val revision = uri.getFragment
|
||||||
|
VcsUriFragment.validate(revision)
|
||||||
Some { () =>
|
Some { () =>
|
||||||
creates(localCopy) {
|
creates(localCopy) {
|
||||||
run(cwd = None, log = None, "svn", "checkout", "-q", "-r", revision, from, to)
|
run(cwd = None, log = None, "svn", "checkout", "-q", "-r", revision, from, to)
|
||||||
|
|
@ -87,6 +87,7 @@ object Resolvers {
|
||||||
|
|
||||||
if (uri.hasFragment) {
|
if (uri.hasFragment) {
|
||||||
val branch = uri.getFragment
|
val branch = uri.getFragment
|
||||||
|
VcsUriFragment.validate(branch)
|
||||||
Some { () =>
|
Some { () =>
|
||||||
creates(localCopy) {
|
creates(localCopy) {
|
||||||
run(cwd = None, log = None, "hg", "clone", "-q", from, localCopy.getAbsolutePath)
|
run(cwd = None, log = None, "hg", "clone", "-q", from, localCopy.getAbsolutePath)
|
||||||
|
|
@ -108,6 +109,7 @@ object Resolvers {
|
||||||
|
|
||||||
if (uri.hasFragment) {
|
if (uri.hasFragment) {
|
||||||
val branch = uri.getFragment
|
val branch = uri.getFragment
|
||||||
|
VcsUriFragment.validate(branch)
|
||||||
Some { () =>
|
Some { () =>
|
||||||
creates(localCopy) {
|
creates(localCopy) {
|
||||||
run(cwd = None, log = None, "git", "clone", from, localCopy.getAbsolutePath)
|
run(cwd = None, log = None, "git", "clone", from, localCopy.getAbsolutePath)
|
||||||
|
|
@ -132,11 +134,7 @@ object Resolvers {
|
||||||
}
|
}
|
||||||
|
|
||||||
private def run(cwd: Option[File], log: Option[Logger], command: String*): Unit = {
|
private def run(cwd: Option[File], log: Option[Logger], command: String*): Unit = {
|
||||||
val process = Process(
|
val process = Process(command, cwd)
|
||||||
if (Util.isNonCygwinWindows) "cmd" +: "/c" +: command
|
|
||||||
else command,
|
|
||||||
cwd
|
|
||||||
)
|
|
||||||
val result = (log match {
|
val result = (log match {
|
||||||
case Some(log) =>
|
case Some(log) =>
|
||||||
val io = BasicIO(false, log).withInput(_.close())
|
val io = BasicIO(false, log).withInput(_.close())
|
||||||
|
|
@ -295,7 +293,11 @@ object Resolvers {
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
val headBefore = captureOutput(Some(localCopy), "git", "rev-parse", "HEAD")
|
val headBefore = captureOutput(Some(localCopy), "git", "rev-parse", "HEAD")
|
||||||
val ref = if (fromURI(uri).hasFragment) uri.getFragment else "HEAD"
|
val ref = if (fromURI(uri).hasFragment) {
|
||||||
|
val f = uri.getFragment
|
||||||
|
VcsUriFragment.validate(f)
|
||||||
|
f
|
||||||
|
} else "HEAD"
|
||||||
run(Some(localCopy), Some(log), "git", "fetch", "origin", ref)
|
run(Some(localCopy), Some(log), "git", "fetch", "origin", ref)
|
||||||
run(Some(localCopy), Some(log), "git", "reset", "--hard", "FETCH_HEAD")
|
run(Some(localCopy), Some(log), "git", "reset", "--hard", "FETCH_HEAD")
|
||||||
markUpdated(localCopy)
|
markUpdated(localCopy)
|
||||||
|
|
@ -310,16 +312,14 @@ object Resolvers {
|
||||||
}
|
}
|
||||||
|
|
||||||
private def captureOutput(cwd: Option[File], command: String*): String =
|
private def captureOutput(cwd: Option[File], command: String*): String =
|
||||||
Process(
|
Process(command, cwd).!!.trim
|
||||||
if (Util.isNonCygwinWindows) "cmd" +: "/c" +: command else command,
|
|
||||||
cwd
|
|
||||||
).!!.trim
|
|
||||||
|
|
||||||
private def updateMercurial(localCopy: File, uri: URI, log: Logger): Boolean =
|
private def updateMercurial(localCopy: File, uri: URI, log: Logger): Boolean =
|
||||||
try {
|
try {
|
||||||
val idBefore = captureOutput(Some(localCopy), "hg", "id", "-i")
|
val idBefore = captureOutput(Some(localCopy), "hg", "id", "-i")
|
||||||
if (fromURI(uri).hasFragment) {
|
if (fromURI(uri).hasFragment) {
|
||||||
val branch = uri.getFragment
|
val branch = uri.getFragment
|
||||||
|
VcsUriFragment.validate(branch)
|
||||||
run(Some(localCopy), Some(log), "hg", "pull")
|
run(Some(localCopy), Some(log), "hg", "pull")
|
||||||
run(Some(localCopy), Some(log), "hg", "update", branch)
|
run(Some(localCopy), Some(log), "hg", "update", branch)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -340,6 +340,7 @@ object Resolvers {
|
||||||
val revBefore = captureOutput(Some(localCopy), "svn", "info", "--show-item", "revision")
|
val revBefore = captureOutput(Some(localCopy), "svn", "info", "--show-item", "revision")
|
||||||
if (fromURI(uri).hasFragment) {
|
if (fromURI(uri).hasFragment) {
|
||||||
val revision = uri.getFragment
|
val revision = uri.getFragment
|
||||||
|
VcsUriFragment.validate(revision)
|
||||||
run(Some(localCopy), Some(log), "svn", "update", "-q", "-r", revision)
|
run(Some(localCopy), Some(log), "svn", "update", "-q", "-r", revision)
|
||||||
} else {
|
} else {
|
||||||
run(Some(localCopy), Some(log), "svn", "update", "-q")
|
run(Some(localCopy), Some(log), "svn", "update", "-q")
|
||||||
|
|
|
||||||
|
|
@ -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 == '+'
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue