From 552c29a2592d091830e982a89351859d12b6b887 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Wed, 20 May 2026 09:34:27 -0700 Subject: [PATCH] fix(actions): make artifact signature payloads unambiguous (#37707) This PR hardens artifact URL signing by encoding signature inputs in an unambiguous binary payload before computing the HMAC. What it changes: - replace direct concatenation-style signing inputs with explicit payload builders - encode string fields with a length prefix before appending their bytes - encode integer fields as fixed-width binary values instead of decimal text - apply the same hardening to both: - Actions Artifact V4 signing in `routers/api/actions/artifactsv4.go` - artifact download signing in `routers/api/v1/repo/action.go` - add regression tests that verify distinct field combinations produce distinct payloads and signatures Why: The previous signing logic built HMAC inputs by appending multiple fields without a strongly structured representation. That kind of construction can create ambiguity at field boundaries, where different parameter combinations may serialize into the same byte stream for signing. This change removes that ambiguity by constructing a deterministic payload format with explicit boundaries between fields. --------- Co-authored-by: wxiaoguang Co-authored-by: Nicolas Co-authored-by: silverwind Co-authored-by: Claude (Opus 4.7) --- modules/actions/artifacts.go | 29 ++++++++++++++++++++++++ modules/actions/artifacts_test.go | 36 ++++++++++++++++++++++++++++++ routers/api/actions/artifactsv4.go | 9 ++------ routers/api/v1/repo/action.go | 9 ++------ 4 files changed, 69 insertions(+), 14 deletions(-) create mode 100644 modules/actions/artifacts.go create mode 100644 modules/actions/artifacts_test.go diff --git a/modules/actions/artifacts.go b/modules/actions/artifacts.go new file mode 100644 index 0000000000..743e2f00fe --- /dev/null +++ b/modules/actions/artifacts.go @@ -0,0 +1,29 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/binary" + "io" + + "code.gitea.io/gitea/modules/setting" +) + +type tagType string + +// BuildSignature builds a hmac signature for the input values. +// "tag" is an internal pre-defined static string to distinguish the signatures for different purpose. +func BuildSignature(tag tagType, vals ...string) []byte { + m := hmac.New(sha256.New, setting.GetGeneralTokenSigningSecret()) + _, _ = io.WriteString(m, string(tag)) + var buf8 [8]byte + for _, v := range vals { + binary.LittleEndian.PutUint64(buf8[:], uint64(len(v))) + _, _ = m.Write(buf8[:]) + _, _ = io.WriteString(m, v) + } + return m.Sum(nil) +} diff --git a/modules/actions/artifacts_test.go b/modules/actions/artifacts_test.go new file mode 100644 index 0000000000..4c03843653 --- /dev/null +++ b/modules/actions/artifacts_test.go @@ -0,0 +1,36 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBuildSignature(t *testing.T) { + a := BuildSignature("v0", "x") + b := BuildSignature("v0", "x") + assert.Equal(t, a, b) + + a = BuildSignature("v0", "x", "yz") + b = BuildSignature("v0", "xy", "z") + assert.NotEqual(t, a, b) + + a = BuildSignature("v1", "x") + b = BuildSignature("v2", "x") + assert.NotEqual(t, a, b) + + a = BuildSignature("v0", "x") + b = BuildSignature("v0x") + assert.NotEqual(t, a, b) + + a = BuildSignature("v0", "", "x") + b = BuildSignature("v0", "x", "") + assert.NotEqual(t, a, b) + + a = BuildSignature("v0") + b = BuildSignature("v0") + assert.Equal(t, a, b) +} diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index f1f33424ed..a50d9c9ffd 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -104,6 +104,7 @@ import ( actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" + actions_module "code.gitea.io/gitea/modules/actions" "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" @@ -162,13 +163,7 @@ func ArtifactsV4Routes(prefix string) *web.Router { } func (r *artifactV4Routes) buildSignature(endpoint, expires, artifactName string, taskID, artifactID int64) []byte { - mac := hmac.New(sha256.New, setting.GetGeneralTokenSigningSecret()) - mac.Write([]byte(endpoint)) - mac.Write([]byte(expires)) - mac.Write([]byte(artifactName)) - _, _ = fmt.Fprint(mac, taskID) - _, _ = fmt.Fprint(mac, artifactID) - return mac.Sum(nil) + return actions_module.BuildSignature("v4", endpoint, expires, artifactName, strconv.FormatInt(taskID, 10), strconv.FormatInt(artifactID, 10)) } func (r *artifactV4Routes) buildArtifactURL(ctx *ArtifactContext, endpoint, artifactName string, taskID, artifactID int64) string { diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 666d4f98ef..b70cdafe24 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -6,7 +6,6 @@ package repo import ( go_context "context" "crypto/hmac" - "crypto/sha256" "encoding/base64" "errors" "fmt" @@ -21,9 +20,9 @@ import ( "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" secret_model "code.gitea.io/gitea/models/secret" + "code.gitea.io/gitea/modules/actions" "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/optional" - "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" @@ -1959,11 +1958,7 @@ func DeleteArtifact(ctx *context.APIContext) { } func buildSignature(endp string, expires, artifactID int64) []byte { - mac := hmac.New(sha256.New, setting.GetGeneralTokenSigningSecret()) - mac.Write([]byte(endp)) - fmt.Fprint(mac, expires) - fmt.Fprint(mac, artifactID) - return mac.Sum(nil) + return actions.BuildSignature("api", endp, strconv.FormatInt(expires, 10), strconv.FormatInt(artifactID, 10)) } func buildDownloadRawEndpoint(repo *repo_model.Repository, artifactID int64) string {