mirror of https://github.com/sbt/sbt.git
[2.x] fix: Preserve '=' in remote cache header values (#9534)
**Problem**
`remoteCacheHeaders` entries are parsed in GrpcActionCacheStore.AuthCallCredentials
with h.split("="), which splits on every =. Java's split discards trailing empty
strings, so a Basic auth header such as authorization=Basic dXNlcjpwdw== produces
exactly two elements and matches List(k, v) with the base64 padding silently removed.
The truncated credential is rejected by the cache server with UNAUTHENTICATED.
**Solution**
Split on the first = only, keeping the remainder of the string verbatim as the header
value. The error case narrows to a header containing no = at all.
Generated-by: Claude Opus 5
This commit is contained in:
parent
bb141109e1
commit
56c3633874
|
|
@ -142,9 +142,12 @@ object GrpcActionCacheStore:
|
||||||
|
|
||||||
class AuthCallCredentials(remoteHeaders: List[String]) extends CallCredentials:
|
class AuthCallCredentials(remoteHeaders: List[String]) extends CallCredentials:
|
||||||
val pairs = remoteHeaders.map: h =>
|
val pairs = remoteHeaders.map: h =>
|
||||||
h.split("=").toList match
|
// Split on the first '=' only. Splitting on every '=' would drop trailing
|
||||||
|
// padding from values such as Basic auth credentials ("Basic dXNlcjpwdw==")
|
||||||
|
// and reject values that legitimately contain '='.
|
||||||
|
h.split("=", 2).toList match
|
||||||
case List(k, v) => Metadata.Key.of(k, Metadata.ASCII_STRING_MARSHALLER) -> v
|
case List(k, v) => Metadata.Key.of(k, Metadata.ASCII_STRING_MARSHALLER) -> v
|
||||||
case _ => sys.error("remote header must contain one '='")
|
case _ => sys.error("remote header must contain '='")
|
||||||
override def applyRequestMetadata(
|
override def applyRequestMetadata(
|
||||||
requestInfo: CallCredentials.RequestInfo,
|
requestInfo: CallCredentials.RequestInfo,
|
||||||
executor: java.util.concurrent.Executor,
|
executor: java.util.concurrent.Executor,
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,27 @@ object GrpcActionCacheStoreTest extends verify.BasicTestSuite:
|
||||||
// Distinct Deadline instances derived at call time, not a single shared frozen one.
|
// Distinct Deadline instances derived at call time, not a single shared frozen one.
|
||||||
assert(!deadline1.eq(deadline2))
|
assert(!deadline1.eq(deadline2))
|
||||||
|
|
||||||
|
// Regression test: header values may legitimately contain '=' -- Basic auth credentials
|
||||||
|
// end in base64 padding. Splitting on every '=' silently truncated the value, so the
|
||||||
|
// server rejected the credential with UNAUTHENTICATED while the build still succeeded,
|
||||||
|
// leaving the cache permanently empty with no error reported.
|
||||||
|
test("header values retain '=' such as base64 padding"):
|
||||||
|
val twoPad = GrpcActionCacheStore.AuthCallCredentials(List("authorization=Basic dXNlcjpwdw=="))
|
||||||
|
val (key, value) = twoPad.pairs.head
|
||||||
|
assert(key.name == "authorization")
|
||||||
|
assert(value == "Basic dXNlcjpwdw==")
|
||||||
|
|
||||||
|
val onePad = GrpcActionCacheStore.AuthCallCredentials(List("authorization=Basic dXNlcjpwdzE="))
|
||||||
|
assert(onePad.pairs.head._2 == "Basic dXNlcjpwdzE=")
|
||||||
|
|
||||||
|
// An interior '=' is part of the value, not a second separator.
|
||||||
|
val interior = GrpcActionCacheStore.AuthCallCredentials(List("x-api-key=ab=cd"))
|
||||||
|
assert(interior.pairs.head._2 == "ab=cd")
|
||||||
|
|
||||||
|
// No '=' at all remains an error.
|
||||||
|
intercept[RuntimeException]:
|
||||||
|
GrpcActionCacheStore.AuthCallCredentials(List("bogus")).pairs
|
||||||
|
|
||||||
private def newStore(): GrpcActionCacheStore =
|
private def newStore(): GrpcActionCacheStore =
|
||||||
val base = Files.createTempDirectory("grpc-action-cache-test")
|
val base = Files.createTempDirectory("grpc-action-cache-test")
|
||||||
val disk = DiskActionCacheStore(base, PlainVirtualFileConverter.converter)
|
val disk = DiskActionCacheStore(base, PlainVirtualFileConverter.converter)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue