Research/gitea-artifact-v4-readall
0-dayNo CVEMediumPublic

Gitea, artifact v4 unbounded ReadAll

Gitea 1.27.3 artifact v4 parseProtobufBody does io.ReadAll with no cap. Job-summary on the same instance already LimitReaders at 1 MiB. A running Actions job token can POST a 12 MiB CreateArtifact. Not process-kill. No CVE yet.

Name
Gitea, artifact v4 unbounded ReadAll
Type
0-day analysis
CVE
n/a
CVE Risk
medium
Disclosure Status
public
Vendor
Gitea
Affected
Gitea through v1.27.3 (146cc3e) with Actions enabled; unpublished. Needs a running job token, not a random PAT. Fork-PR jobs still NeedApproval.
Published
29 Sept 2026
Updated
29 Sept 2026
Tags
0-day, gitea, actions, dos, cwe-400, authenticated

job-summary has a cap. artifact v4 does not

I am @abraxas_null. The proof of concept is on GitHub: abraxas/gitea-artifact-v4-readall (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, runner-config.yaml. Authorized lab only. It talks to loopback.

This is Gitea v1.27.3 (146cc3e). No CVE yet. CWE-400. 6.5 Medium (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H).

How I found it

After the GHSA wave I looked at Actions body handling. Fork-PR jobs still NeedApproval on 1.27.3, so this is not an unauth fork-PR skip. I looked at how the Actions API reads request bodies.

job_summary.go already caps at MaxJobSummarySize (1 MiB) with io.LimitReader. parseProtobufBody in artifact v4 does io.ReadAll(ctx.Req.Body) with no cap. CreateArtifact, Finalize, List, Get, Delete all go through it. Job-summary learned LimitReader. Artifact v4 did not copy it.

A random user PAT is not enough. You need a running Actions job token. I stood up gitea/act_runner:0.2.13, seeded a hold workflow so a job token exists, and POSTed a 12 MiB CreateArtifact. HTTP 200 ok plus signedUploadUrl. Then 8 MiB job-summary: 400 invalid summary. GET /api/v1/version still 200. Gitea stayed up. The witness is the missing cap, not an OOM. Lab mem_limit is 1g. Body capped at 12 MiB so the host is not the experiment.

I am not printing a 12 MiB protobuf you can paste at someone else's /twirp/.../CreateArtifact. The unbounded ReadAll next to the existing LimitReader is the useful part.

Wrong turns already recorded: CreateArtifact without a job token (401 is not the bug); process kill / OOM (SUCCESS is 12 MiB -> 200 while 8 MiB summary -> 400); treating signedUploadUrl as RCE; fork-PR unauth; a reverse shell. Theatre.

The unbounded ReadAll

parseProtobufBody:

Go
func (r *artifactV4Routes) parseProtobufBody(ctx *ArtifactContext, req protoreflect.ProtoMessage) bool {
	body, err := io.ReadAll(ctx.Req.Body)
	if err != nil {
		log.Error("Error decode request body: %v", err)
		ctx.HTTPError(http.StatusInternalServerError, "Error decode request body")
		return false
	}
	err = protojson.Unmarshal(body, req)

Sibling job_summary.go:

Go
body, err := io.ReadAll(io.LimitReader(ctx.Req.Body, actions_model.MaxJobSummarySize+1))

Same instance. Two body readers. One has a number.

What an attacker can do

From a running Actions job (their own repo, or a fork-PR that already passed NeedApproval), POST an arbitrarily large v4 CreateArtifact body and force Gitea to buffer it all. Job-summary on the same instance already rejects anything over 1 MiB.

That is memory pressure on the Gitea process. Lab proved the missing cap (12 MiB -> 200). It did not kill the process. It is not RCE. It is not unauthenticated. A stolen user PAT without a live job does not reach this path.

The lab (run this at home)

Source of truth is lab/. Image gitea/gitea:1.27.3 plus gitea/act_runner:0.2.13. Host bind 127.0.0.1:18135. In-compose ROOT_URL is http://gitea:3000/ so the runner can register. Host executor only. No docker.sock. run.sh seeds a hold workflow so a job token exists.

Dockerfile
# Loopback lab image pin. Full stack: docker-compose.yml
FROM gitea/gitea:1.27.3

docker-compose.yml (truncated to the two services; full file on GitHub):

YAML
name: gitea-artifact-v4-readall

services:
  gitea:
    build: .
    image: gitea-artifact-v4-readall:1.27.3
    ports:
      - "127.0.0.1:18135:3000"
    environment:
      GITEA__server__DOMAIN: gitea
      GITEA__server__HTTP_PORT: "3000"
      GITEA__server__ROOT_URL: http://gitea:3000/
      GITEA__actions__ENABLED: "true"
    mem_limit: 1g

  runner:
    image: docker.io/gitea/act_runner:0.2.13
    depends_on:
      gitea:
        condition: service_healthy
    environment:
      GITEA_INSTANCE_URL: http://gitea:3000
      GITEA_RUNNER_LABELS: ubuntu-latest:host
    volumes:
      - ./runner-config.yaml:/config.yaml:ro
    mem_limit: 512m
Plain text
git clone https://github.com/abraxas/gitea-artifact-v4-readall
cd gitea-artifact-v4-readall/lab
./run.sh

The proof of concept is written for 127.0.0.1:18135. Do not publish the port off loopback.

What the tree actually consumes

Running job token. POST CreateArtifact with a 12 MiB body. POST job-summary with an 8 MiB body. Version still answers.

A few other ways to lose without learning anything:

  • CreateArtifact without a job token. 401 is not the bug.
  • Process kill / OOM. Lab mem_limit is 1g. SUCCESS is 12 MiB -> 200 while 8 MiB summary -> 400.
  • Treating signedUploadUrl as RCE. It is an upload URL for the artifact service.
  • Fork-PR unauth. 1.27.3 still NeedApproval on those jobs.
  • A reverse shell. Theatre. The witness is unbounded ReadAll vs LimitReader.

Last lab run, trimmed:

Plain text
v4-create status=200 sent=12582912
v4-unbounded-readall=1
job-summary status=400 sent=8388608 invalid summary cap=MaxJobSummarySize=1048576
gitea-still-up status=200
SUCCESS GITEA-ARTIFACT-V4-READALL

The client that produced it is on GitHub. I am not reprinting the job token.

What this is not

It is not "Gitea Actions never caps bodies." Job-summary does. Artifact v4 parse does not. It is not unauthenticated. It is not a fork-PR skip of NeedApproval. It is not RCE. Lab did not kill the process.

The fix

Wrap parseProtobufBody in io.LimitReader at a proto-sized cap, same pattern as MaxJobSummarySize. Re-run the loopback client against a patched build: 12 MiB CreateArtifact must fail the same class of way 8 MiB job-summary already does.

I am not going to print a CreateArtifact body you can paste at someone else's twirp endpoint. The missing LimitReader is the useful part. If you own the box, run the proof of concept against loopback.

Same product, other unpublished labs on this tag: hostmatcher 0.0.0.0/8 · public-only PAT org · stargazers hidden · follow existence · restore file:// · keys IDOR / git redirect.

References

Gitea, artifact v4 unbounded ReadAll · Abraxas Labs