Skip to content

Commit ee2b10f

Browse files
authored
WX-1385 Reject blob URLs with external SAS tokens as unparsable (#7347)
1 parent e3a923f commit ee2b10f

File tree

3 files changed

+49
-14
lines changed

3 files changed

+49
-14
lines changed

filesystems/blob/src/main/scala/cromwell/filesystems/blob/BlobPathBuilder.scala

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package cromwell.filesystems.blob
22

3+
import akka.http.scaladsl.model.Uri
34
import com.azure.storage.blob.nio.AzureBlobFileAttributes
45
import com.google.common.net.UrlEscapers
56
import cromwell.core.path.{NioPath, Path, PathBuilder}
@@ -21,6 +22,8 @@ object BlobPathBuilder {
2122
s"Malformed Blob URL for this builder: The endpoint $endpoint doesn't contain the expected host string '{SA}.blob.core.windows.net/'"
2223
def invalidBlobContainerMessage(endpoint: EndpointURL) =
2324
s"Malformed Blob URL for this builder: Could not parse container"
25+
val externalToken =
26+
"Rejecting pre-signed SAS URL so that filesystem selection falls through to HTTP filesystem"
2427
def parseURI(string: String): Try[URI] = Try(URI.create(UrlEscapers.urlFragmentEscaper().escape(string)))
2528
def parseStorageAccount(uri: URI): Try[StorageAccountName] = uri.getHost
2629
.split("\\.")
@@ -50,19 +53,34 @@ object BlobPathBuilder {
5053
def validateBlobPath(string: String): BlobPathValidation = {
5154
val blobValidation = for {
5255
testUri <- parseURI(string)
53-
testEndpoint = EndpointURL(testUri.getScheme + "://" + testUri.getHost())
54-
testStorageAccount <- parseStorageAccount(testUri)
56+
testEndpoint = EndpointURL(testUri.getScheme + "://" + testUri.getHost)
57+
_ <- parseStorageAccount(testUri)
5558
testContainer = testUri.getPath.split("/").find(_.nonEmpty)
56-
isBlobHost = testUri.getHost().contains(blobHostnameSuffix) && testUri.getScheme().contains("https")
57-
blobPathValidation = (isBlobHost, testContainer) match {
58-
case (true, Some(container)) =>
59+
isBlobHost = testUri.getHost.contains(blobHostnameSuffix) && testUri.getScheme.contains("https")
60+
hasToken = hasSasToken(string)
61+
blobPathValidation = (isBlobHost, testContainer, hasToken) match {
62+
case (true, Some(container), false) =>
5963
ValidBlobPath(testUri.getPath.replaceFirst("/" + container, ""), BlobContainerName(container), testEndpoint)
60-
case (false, _) => UnparsableBlobPath(new MalformedURLException(invalidBlobHostMessage(testEndpoint)))
61-
case (true, None) => UnparsableBlobPath(new MalformedURLException(invalidBlobContainerMessage(testEndpoint)))
64+
case (false, _, false) =>
65+
UnparsableBlobPath(new MalformedURLException(invalidBlobHostMessage(testEndpoint)))
66+
case (true, None, false) =>
67+
UnparsableBlobPath(new MalformedURLException(invalidBlobContainerMessage(testEndpoint)))
68+
case (_, _, true) =>
69+
UnparsableBlobPath(new IllegalArgumentException(externalToken))
6270
}
6371
} yield blobPathValidation
6472
blobValidation recover { case t => UnparsableBlobPath(t) } get
6573
}
74+
75+
private def hasSasToken(uri: Uri) = {
76+
// These keys are required for all SAS tokens.
77+
// https://learn.microsoft.com/en-us/rest/api/storageservices/create-service-sas#construct-a-service-sas
78+
val SignedVersionKey = "sv"
79+
val SignatureKey = "sig"
80+
81+
val query = uri.query().toMap
82+
query.isDefinedAt(SignedVersionKey) && query.isDefinedAt(SignatureKey)
83+
}
6684
}
6785

6886
class BlobPathBuilder()(private val fsm: BlobFileSystemManager) extends PathBuilder {

filesystems/blob/src/test/scala/cromwell/filesystems/blob/BlobPathBuilderSpec.scala

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,23 @@ class BlobPathBuilderSpec extends AnyFlatSpec with Matchers with MockSugar {
2727
}
2828
}
2929

30+
it should "reject a path that is otherwise valid, but has a preexisting SAS token" in {
31+
import cromwell.filesystems.blob.BlobPathBuilder.UnparsableBlobPath
32+
33+
// The `.asInstanceOf[UnparsableBlobPath].errorMessage.getMessage` malarkey is necessary
34+
// because Java exceptions compare by reference, while strings are by value
35+
36+
val sasBlob = "https://lz304a1e79fd7359e5327eda.blob.core.windows.net/sc-705b830a-d699-478e-9da6-49661b326e77" +
37+
"?sv=2021-12-02&spr=https&st=2023-12-13T20%3A27%3A55Z&se=2023-12-14T04%3A42%3A55Z&sr=c&sp=racwdlt&sig=blah&rscd=foo"
38+
BlobPathBuilder.validateBlobPath(sasBlob).asInstanceOf[UnparsableBlobPath].errorMessage.getMessage should equal(
39+
UnparsableBlobPath(
40+
new IllegalArgumentException(
41+
"Rejecting pre-signed SAS URL so that filesystem selection falls through to HTTP filesystem"
42+
)
43+
).errorMessage.getMessage
44+
)
45+
}
46+
3047
it should "provide a readable error when getting an illegal nioPath" in {
3148
val endpoint = BlobPathBuilderSpec.buildEndpoint("storageAccount")
3249
val container = BlobContainerName("container")
@@ -39,8 +56,9 @@ class BlobPathBuilderSpec extends AnyFlatSpec with Matchers with MockSugar {
3956
testException should contain(exception)
4057
}
4158

42-
private def testBlobNioStringCleaning(input: String, expected: String) =
43-
BlobPath.cleanedNioPathString(input) shouldBe expected
59+
// The following tests use the `centaurtesting` account injected into CI. They depend on access to the
60+
// container specified below. You may need to log in to az cli locally to get them to pass.
61+
private val subscriptionId: SubscriptionId = SubscriptionId(UUID.fromString("62b22893-6bc1-46d9-8a90-806bb3cce3c9"))
4462

4563
it should "clean the NIO path string when it has a garbled http protocol" in {
4664
testBlobNioStringCleaning(
@@ -69,10 +87,6 @@ class BlobPathBuilderSpec extends AnyFlatSpec with Matchers with MockSugar {
6987
""
7088
)
7189
}
72-
73-
// The following tests use the `centaurtesting` account injected into CI. They depend on access to the
74-
// container specified below. You may need to log in to az cli locally to get them to pass.
75-
private val subscriptionId: SubscriptionId = SubscriptionId(UUID.fromString("62b22893-6bc1-46d9-8a90-806bb3cce3c9"))
7690
private val endpoint: EndpointURL = BlobPathBuilderSpec.buildEndpoint("centaurtesting")
7791
private val container: BlobContainerName = BlobContainerName("test-blob")
7892

@@ -82,6 +96,9 @@ class BlobPathBuilderSpec extends AnyFlatSpec with Matchers with MockSugar {
8296
new BlobPathBuilder()(fsm)
8397
}
8498

99+
private def testBlobNioStringCleaning(input: String, expected: String) =
100+
BlobPath.cleanedNioPathString(input) shouldBe expected
101+
85102
it should "read md5 from small files <5g" in {
86103
val builder = makeBlobPathBuilder()
87104
val evalPath = "/testRead.txt"

project/Dependencies.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -499,7 +499,7 @@ object Dependencies {
499499
List("scalatest", "mysql", "mariadb", "postgresql")
500500
.map(name => "com.dimafeng" %% s"testcontainers-scala-$name" % testContainersScalaV % Test)
501501

502-
val blobFileSystemDependencies: List[ModuleID] = azureDependencies ++ wsmDependencies
502+
val blobFileSystemDependencies: List[ModuleID] = azureDependencies ++ wsmDependencies ++ akkaHttpDependencies
503503

504504
val s3FileSystemDependencies: List[ModuleID] = junitDependencies
505505

0 commit comments

Comments
 (0)