[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:
KilianSwissborg 2026-07-31 20:50:01 +02:00 committed by GitHub
parent bb141109e1
commit 56c3633874
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 26 additions and 2 deletions

View File

@ -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,

View File

@ -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)