From 1c2d5e9b03f71dd12d450b2af9a79f2557b50226 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 20 May 2026 19:16:21 +0200 Subject: [PATCH] fix(actions): make artifact signature payloads unambiguous (#37707) (#37795) 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. Backport #37707 Co-authored-by: Lunny Xiao Co-authored-by: wxiaoguang Co-authored-by: silverwind Co-authored-by: Claude (Opus 4.7) --- modules/actions/artifacts.go | 20 +++++++++++++++++ modules/actions/artifacts_test.go | 36 ++++++++++++++++++++++++++++++ routers/api/actions/artifactsv4.go | 8 +------ routers/api/v1/repo/action.go | 8 +------ 4 files changed, 58 insertions(+), 14 deletions(-) create mode 100644 modules/actions/artifacts_test.go diff --git a/modules/actions/artifacts.go b/modules/actions/artifacts.go index 4884eb42e80..fcf24dbf5f7 100644 --- a/modules/actions/artifacts.go +++ b/modules/actions/artifacts.go @@ -4,6 +4,10 @@ package actions import ( + "crypto/hmac" + "crypto/sha256" + "encoding/binary" + "io" "net/http" "strings" @@ -15,6 +19,22 @@ import ( "code.gitea.io/gitea/services/context" ) +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) +} + // IsArtifactV4 detects whether the artifact is likely from v4. // V4 backend stores the files as a single combined zip file per artifact, and ensures ContentEncoding contains a slash // (otherwise this uses application/zip instead of the custom mime type), which is not the case for the old backend. diff --git a/modules/actions/artifacts_test.go b/modules/actions/artifacts_test.go new file mode 100644 index 00000000000..4c038436539 --- /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 e86645cb0cf..b102c8b132e 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -161,13 +161,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.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 7ac8a10575c..04968185411 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" @@ -23,7 +22,6 @@ import ( 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/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" @@ -1770,11 +1768,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 {