Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WX-1385 Reject blob URLs with external SAS tokens as unparsable #7347

Merged
merged 5 commits into from
Jan 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package cromwell.filesystems.blob

import akka.http.scaladsl.model.Uri
import com.azure.storage.blob.nio.AzureBlobFileAttributes
import com.google.common.net.UrlEscapers
import cromwell.core.path.{NioPath, Path, PathBuilder}
Expand All @@ -21,6 +22,8 @@
s"Malformed Blob URL for this builder: The endpoint $endpoint doesn't contain the expected host string '{SA}.blob.core.windows.net/'"
def invalidBlobContainerMessage(endpoint: EndpointURL) =
s"Malformed Blob URL for this builder: Could not parse container"
val externalToken =
"Rejecting pre-signed SAS URL so that filesystem selection falls through to HTTP filesystem"
def parseURI(string: String): Try[URI] = Try(URI.create(UrlEscapers.urlFragmentEscaper().escape(string)))
def parseStorageAccount(uri: URI): Try[StorageAccountName] = uri.getHost
.split("\\.")
Expand Down Expand Up @@ -50,19 +53,34 @@
def validateBlobPath(string: String): BlobPathValidation = {
val blobValidation = for {
testUri <- parseURI(string)
testEndpoint = EndpointURL(testUri.getScheme + "://" + testUri.getHost())
testStorageAccount <- parseStorageAccount(testUri)
testEndpoint = EndpointURL(testUri.getScheme + "://" + testUri.getHost)
_ <- parseStorageAccount(testUri)
testContainer = testUri.getPath.split("/").find(_.nonEmpty)
isBlobHost = testUri.getHost().contains(blobHostnameSuffix) && testUri.getScheme().contains("https")
blobPathValidation = (isBlobHost, testContainer) match {
case (true, Some(container)) =>
isBlobHost = testUri.getHost.contains(blobHostnameSuffix) && testUri.getScheme.contains("https")
hasToken = hasSasToken(string)
blobPathValidation = (isBlobHost, testContainer, hasToken) match {
case (true, Some(container), false) =>
ValidBlobPath(testUri.getPath.replaceFirst("/" + container, ""), BlobContainerName(container), testEndpoint)
case (false, _) => UnparsableBlobPath(new MalformedURLException(invalidBlobHostMessage(testEndpoint)))
case (true, None) => UnparsableBlobPath(new MalformedURLException(invalidBlobContainerMessage(testEndpoint)))
case (false, _, false) =>
UnparsableBlobPath(new MalformedURLException(invalidBlobHostMessage(testEndpoint)))

Check warning on line 65 in filesystems/blob/src/main/scala/cromwell/filesystems/blob/BlobPathBuilder.scala

View check run for this annotation

Codecov / codecov/patch

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

Added line #L65 was not covered by tests
case (true, None, false) =>
UnparsableBlobPath(new MalformedURLException(invalidBlobContainerMessage(testEndpoint)))

Check warning on line 67 in filesystems/blob/src/main/scala/cromwell/filesystems/blob/BlobPathBuilder.scala

View check run for this annotation

Codecov / codecov/patch

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

Added line #L67 was not covered by tests
case (_, _, true) =>
UnparsableBlobPath(new IllegalArgumentException(externalToken))

Check warning on line 69 in filesystems/blob/src/main/scala/cromwell/filesystems/blob/BlobPathBuilder.scala

View check run for this annotation

Codecov / codecov/patch

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

Added line #L69 was not covered by tests
}
} yield blobPathValidation
blobValidation recover { case t => UnparsableBlobPath(t) } get
}

private def hasSasToken(uri: Uri) = {
// These keys are required for all SAS tokens.
// https://learn.microsoft.com/en-us/rest/api/storageservices/create-service-sas#construct-a-service-sas
val SignedVersionKey = "sv"
val SignatureKey = "sig"

val query = uri.query().toMap
query.isDefinedAt(SignedVersionKey) && query.isDefinedAt(SignatureKey)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👏 Nice, I like this definition.

}
}

class BlobPathBuilder()(private val fsm: BlobFileSystemManager) extends PathBuilder {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,23 @@ class BlobPathBuilderSpec extends AnyFlatSpec with Matchers with MockSugar {
}
}

it should "reject a path that is otherwise valid, but has a preexisting SAS token" in {
import cromwell.filesystems.blob.BlobPathBuilder.UnparsableBlobPath

// The `.asInstanceOf[UnparsableBlobPath].errorMessage.getMessage` malarkey is necessary
// because Java exceptions compare by reference, while strings are by value

val sasBlob = "https://lz304a1e79fd7359e5327eda.blob.core.windows.net/sc-705b830a-d699-478e-9da6-49661b326e77" +
"?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"
BlobPathBuilder.validateBlobPath(sasBlob).asInstanceOf[UnparsableBlobPath].errorMessage.getMessage should equal(
UnparsableBlobPath(
new IllegalArgumentException(
"Rejecting pre-signed SAS URL so that filesystem selection falls through to HTTP filesystem"
)
).errorMessage.getMessage
)
}

it should "provide a readable error when getting an illegal nioPath" in {
val endpoint = BlobPathBuilderSpec.buildEndpoint("storageAccount")
val container = BlobContainerName("container")
Expand All @@ -39,8 +56,9 @@ class BlobPathBuilderSpec extends AnyFlatSpec with Matchers with MockSugar {
testException should contain(exception)
}

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

it should "clean the NIO path string when it has a garbled http protocol" in {
testBlobNioStringCleaning(
Expand Down Expand Up @@ -69,10 +87,6 @@ class BlobPathBuilderSpec extends AnyFlatSpec with Matchers with MockSugar {
""
)
}

// The following tests use the `centaurtesting` account injected into CI. They depend on access to the
// container specified below. You may need to log in to az cli locally to get them to pass.
private val subscriptionId: SubscriptionId = SubscriptionId(UUID.fromString("62b22893-6bc1-46d9-8a90-806bb3cce3c9"))
private val endpoint: EndpointURL = BlobPathBuilderSpec.buildEndpoint("centaurtesting")
private val container: BlobContainerName = BlobContainerName("test-blob")

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

private def testBlobNioStringCleaning(input: String, expected: String) =
BlobPath.cleanedNioPathString(input) shouldBe expected

it should "read md5 from small files <5g" in {
val builder = makeBlobPathBuilder()
val evalPath = "/testRead.txt"
Expand Down
2 changes: 1 addition & 1 deletion project/Dependencies.scala
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,7 @@ object Dependencies {
List("scalatest", "mysql", "mariadb", "postgresql")
.map(name => "com.dimafeng" %% s"testcontainers-scala-$name" % testContainersScalaV % Test)

val blobFileSystemDependencies: List[ModuleID] = azureDependencies ++ wsmDependencies
val blobFileSystemDependencies: List[ModuleID] = azureDependencies ++ wsmDependencies ++ akkaHttpDependencies
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added this dependency because akka.http.scaladsl.model.Uri has much better query parsing compared to java.net.URI.


val s3FileSystemDependencies: List[ModuleID] = junitDependencies

Expand Down
Loading