Summary
Gitea Actions Artifacts V4 signed URL HMAC ambiguity allows cross-repository artifact read and cross-task upload-state write
Advisory details
Summary
Gitea Actions Artifacts V4 signed upload/download URLs can be rewritten to access a different running task and repository context while preserving the original HMAC signature. An attacker with permission to run a Gitea Actions job can turn a signed URL for an attacker-controlled artifact into a URL that reads artifacts from another task context, or writes attacker-controlled data into another task's artifact upload staging context, including in a private repository.
This is one vulnerability with two exploit paths:
DownloadArtifact: cross-task/cross-repository artifact read, givingC:H.UploadArtifact: cross-task artifact staging write and metadata mutation, givingI:H.
Details
The root cause is that the V4 artifact signed URL signature is built from raw concatenated fields without delimiters or length-prefixing:
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)
}
Affected code: routers/api/actions/artifactsv4.go:164-171.
Because artifactName, taskID, and artifactID are concatenated without boundaries, two different URL tuples can produce the same HMAC input. For example:
signed tuple:
artifactName = "artifact-795-153"
taskID = 48
artifactID = <attacker artifact id>
forged tuple:
artifactName = "artifact-795-1"
taskID = 53
artifactID = 48<attacker artifact id>
The final HMAC input suffix is identical:
artifact-795-15348<attacker artifact id>
The attacker does not need to know the target artifact's database artifactID. The forged URL's artifactID only needs to carry digits that preserve the original HMAC input. After verification, the actual target artifact is looked up by target task/run/attempt and artifactName, not by the signed artifactID.
The signed URL handlers are unauthenticated and use ArtifactV4Contexter() only:
m.Group("", func() {
m.Put("UploadArtifact", r.uploadArtifact)
m.Get("DownloadArtifact", r.downloadArtifact)
}, ArtifactV4Contexter())
Affected code: routers/api/actions/artifactsv4.go:156-159.
After verifying the HMAC, verifySignature() trusts the URL-controlled taskID, loads that task, checks that it is running, and returns the URL-controlled artifactName. It parses artifactID for the HMAC, but does not load or bind the artifact by that signed artifact ID:
task, err := actions_model.GetTaskByID(ctx, taskID)
...
if task.Status != actions_model.StatusRunning {
...
}
...
return task, artifactName, true
Affected code: routers/api/actions/artifactsv4.go:224-267.
The artifact lookup then uses the URL-selected task's run/attempt plus URL-selected artifact name:
has, err := db.GetEngine(ctx).Where(builder.Eq{
"run_id": runID,
"run_attempt_id": runAttemptID,
"artifact_name": name,
}, builder.Like{"content_encoding", "%/%"}).Get(&art)
Affected code: routers/api/actions/artifactsv4.go:270-278.
For download, the forged URL reaches downloadArtifact(), which verifies the signature, resolves the artifact by the forged task/run/name context, and serves it:
task, artifactName, ok := r.verifySignature(ctx, "DownloadArtifact")
...
artifact, err := r.getArtifactByName(ctx, task.Job.RunID, task.Job.RunAttemptID, artifactName)
...
err = actions.DownloadArtifactV4ReadStorage(ctx.Base, artifact)
Affected code: routers/api/actions/artifactsv4.go:674-693.
For upload, the forged URL reaches uploadArtifact(), which verifies the signature, resolves the target artifact by the forged task/run/name context, appends attacker-controlled data, and updates target artifact metadata:
task, artifactName, ok := r.verifySignature(ctx, "UploadArtifact")
...
artifact, err := r.getArtifactByName(ctx, task.Job.RunID, task.Job.RunAttemptID, artifactName)
...
uploadedLength, err := appendUploadChunkV3(r.fs, ctx, artifact, artifact.RunID, artifact.FileSize)
...
artifact.FileCompressedSize += uploadedLength
artifact.FileSize += uploadedLength
...
actions_model.UpdateArtifactByID(ctx, artifact.ID, artifact)
Affected code: routers/api/actions/artifactsv4.go:382-422.
The strengthened dynamic PoC also opens the storage object created by the forged upload and verifies that the attacker-controlled bytes were written under the target run and target artifact ID staging path. This demonstrates an unauthorized write primitive into the target artifact upload context. The current PoC does not claim that a finalized artifact download already serves modified bytes; for the integrity path, the confirmed impact is cross-context staging write plus target artifact metadata mutation. In normal artifact upload flow, data in this staging area is what FinalizeArtifact later consumes.
V4 artifact creation also appears to omit the existing artifact-name validation:
artifactName := req.Name
...
artifact, err := actions_model.CreateArtifact(ctx, ctx.ActionTask, artifactName, fileName, retentionDays)
Affected code: routers/api/actions/artifactsv4.go:309-337.
This is a hardening issue, but it is not required for the demonstrated tuple-boundary collision. The crafted artifact names in the PoC use ordinary characters such as letters, digits, and hyphens that would normally be valid. The root cause is the ambiguous signed payload plus the post-verification lookup by URL-selected task/run/name context.
The target task must be running, but that is the normal validity window of these signed URLs. The exploit itself is a deterministic parameter rewrite against the active artifact URL flow, so AC:L is appropriate.
Even if the integrity impact is scored conservatively, the DownloadArtifact path independently demonstrates cross-repository private artifact disclosure.
Practical constraints:
- the target task must be in
runningstate; - the target task ID and artifact name must be known or predictable;
- for
DownloadArtifacton newer branches, the target artifact must already beUploadConfirmedwhile the target task is still running. Older V4 implementations differ slightly in artifact lookup/status handling; the PoC validates the branch-specific condition used by each tested release; - the forged decimal
artifactIDmust parse asint64; - the exact storage effect depends on the configured artifact storage backend. The local PoC uses the default non-Azure storage path. The root cause still exists before storage backend handling because the signed URL context is confused before upload/download dispatch.
- the demonstrated exploit applies when Gitea issues its own V4
UploadArtifact/DownloadArtifactsigned URLs. Storage backends or configurations that return direct backend URLs should be assessed separately, because they may bypass these Gitea signed URL handlers.
PoC
Tested against Gitea main checkout:
6a270662690439cabe8582e92c22b04d1f8a3fe9
Verified affected versions tested locally:
main 6a27066269 reproduced dynamically
v1.26.1 afdbd9b7c5 reproduced dynamically
v1.25.5 f913d90ab6 reproduced dynamically
v1.24.7 99053ce4fa reproduced dynamically
v1.23.8 cccd54999a reproduced dynamically
v1.22.6 8eefa1f6de reproduced dynamically with Go 1.22.12 test toolchain
v1.21.11 V4 artifactsv4.go route not present in routers/api/actions; not affected by this V4 signed URL path as tested
Unless otherwise noted, the affected code snippets and line numbers above refer to the tested main checkout 6a27066269. Older release branches contain the same vulnerable signed URL construction and post-verification context confusion, but line
References
- https://github.com/advisories/GHSA-hg5r-vq93-9fv6
- https://github.com/go-gitea/gitea/security/advisories/GHSA-hg5r-vq93-9fv6
- https://nvd.nist.gov/vuln/detail/CVE-2026-58426
- https://github.com/go-gitea/gitea/pull/37707
- https://github.com/go-gitea/gitea/commit/1c2d5e9b03f71dd12d450b2af9a79f2557b50226
- https://blog.gitea.com/release-of-1.26.2
- https://github.com/go-gitea/gitea/releases/tag/v1.26.2
Related vulnerabilities
All Supply chain →- MEDIUMGHSA-73p9-6hrp-8qhr
AIIR verification and policy gates could report success without enforcing the control (fail-open)
- HIGHCVE-2026-54736
Phalcon: Non-constant-time HMAC verification in `Encryption\Crypt::decrypt` (timing side-channel)
- HIGHCVE-2026-54155
node-opcua missing nonce verification in UserNameIdentityToken authentication
- HIGHGHSA-fhgh-wq4q-r37x
uniget CLI: Metadata signature verification only runs when UNIGET_IGNORE_METADATA_SIGNATURE is set
- HIGHCVE-2026-53501
Thumbor has HMAC validation bypass via multiple .replace() calls when removing URL signature
- HIGHCVE-2026-47304
Microsoft Security Advisory CVE-2026-47304 – .NET Security Feature Bypass Vulnerability