diff --git a/routers/api/actions/runner/interceptor.go b/routers/api/actions/runner/interceptor.go index 0621984aae1..fe32012fb4f 100644 --- a/routers/api/actions/runner/interceptor.go +++ b/routers/api/actions/runner/interceptor.go @@ -5,6 +5,7 @@ package runner import ( "context" + "crypto/sha256" "errors" "strings" "time" @@ -17,6 +18,7 @@ import ( "gitea.dev/modules/util" "connectrpc.com/connect/v2" + lru "github.com/hashicorp/golang-lru/v2" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -26,6 +28,10 @@ const ( tokenHeaderKey = protocol.TokenHeader ) +type runnerTokenHash struct{ salt, hash string } + +var verifiedRunnerTokens, _ = lru.New[runnerTokenHash, [sha256.Size]byte](10000) // not auth_model.TokenCache, whose small default size thrashes once more runners poll in turn + // withErrorMessages sends plain errors to runners as CodeUnknown with their message, like connect v1 did. func withErrorMessages(next connect.ServerFunc) connect.ServerFunc { return func(ctx context.Context, spec connect.Spec, stream connect.ServerStream) error { @@ -57,7 +63,7 @@ func withRunner(next connect.ServerFunc) connect.ServerFunc { } return status.Error(codes.Internal, err.Error()) } - if !util.CryptoConstTimeEqual(runner.TokenHash, auth_model.HashToken(token, runner.TokenSalt)) { + if !verifyRunnerToken(runner, token) { return errUnregisteredRunner } @@ -88,6 +94,18 @@ func withRunner(next connect.ServerFunc) connect.ServerFunc { } } +func verifyRunnerToken(runner *actions_model.ActionRunner, token string) bool { + key, digest := runnerTokenHash{runner.TokenSalt, runner.TokenHash}, sha256.Sum256([]byte(token)) + if cached, ok := verifiedRunnerTokens.Get(key); ok && util.CryptoConstTimeEqual(cached[:], digest[:]) { + return true + } + if !util.CryptoConstTimeEqual(runner.TokenHash, auth_model.HashToken(token, runner.TokenSalt)) { + return false + } + verifiedRunnerTokens.Add(key, digest) + return true +} + func getMethodName(spec connect.Spec) string { splits := strings.Split(spec.Procedure, "/") if len(splits) > 0 { diff --git a/routers/api/actions/runner/interceptor_test.go b/routers/api/actions/runner/interceptor_test.go new file mode 100644 index 00000000000..8c0af2b8602 --- /dev/null +++ b/routers/api/actions/runner/interceptor_test.go @@ -0,0 +1,26 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package runner + +import ( + "crypto/sha256" + "testing" + + actions_model "gitea.dev/models/actions" + auth_model "gitea.dev/models/auth" + + "github.com/stretchr/testify/assert" +) + +func TestVerifyRunnerToken(t *testing.T) { + verifiedRunnerTokens.Purge() + runner := &actions_model.ActionRunner{TokenSalt: "salt", TokenHash: auth_model.HashToken("token", "salt")} + assert.True(t, verifyRunnerToken(runner, "token")) + assert.True(t, verifyRunnerToken(runner, "token")) + assert.False(t, verifyRunnerToken(runner, "wrong")) + + other := &actions_model.ActionRunner{TokenSalt: "salt", TokenHash: auth_model.HashToken("other", "salt")} + assert.False(t, verifyRunnerToken(other, "token")) + assert.Equal(t, [][sha256.Size]byte{sha256.Sum256([]byte("token"))}, verifiedRunnerTokens.Values()) +}