Skip to content

Commit 7985a28

Browse files
authored
Merge pull request #79 from git-pkgs/fix-metadata-size-limit
Fix silent truncation of large npm metadata responses
2 parents 773fe55 + 01b4e72 commit 7985a28

4 files changed

Lines changed: 93 additions & 8 deletions

File tree

internal/handler/handler.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package handler
44
import (
55
"context"
66
"database/sql"
7+
"errors"
78
"fmt"
89
"io"
910
"log/slog"
@@ -32,15 +33,26 @@ func containsPathTraversal(path string) bool {
3233

3334
const defaultHTTPTimeout = 30 * time.Second
3435

35-
// maxMetadataSize is the maximum size of upstream metadata responses (50 MB).
36+
// maxMetadataSize is the maximum size of upstream metadata responses (100 MB).
3637
// Package metadata (e.g. npm with many versions) can be large, but unbounded
3738
// reads risk OOM if an upstream misbehaves.
38-
const maxMetadataSize = 50 << 20
39+
const maxMetadataSize = 100 << 20
40+
41+
// ErrMetadataTooLarge is returned when upstream metadata exceeds maxMetadataSize.
42+
var ErrMetadataTooLarge = errors.New("metadata response exceeds size limit")
3943

4044
// ReadMetadata reads an upstream response body with a size limit to prevent OOM
41-
// from unexpectedly large responses.
45+
// from unexpectedly large responses. Returns ErrMetadataTooLarge if the response
46+
// is truncated by the limit.
4247
func ReadMetadata(r io.Reader) ([]byte, error) {
43-
return io.ReadAll(io.LimitReader(r, maxMetadataSize))
48+
data, err := io.ReadAll(io.LimitReader(r, maxMetadataSize+1))
49+
if err != nil {
50+
return nil, err
51+
}
52+
if int64(len(data)) > maxMetadataSize {
53+
return nil, ErrMetadataTooLarge
54+
}
55+
return data, nil
4456
}
4557

4658
// Proxy provides shared functionality for protocol handlers.

internal/handler/npm.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,13 @@ func (h *NPMHandler) handlePackageMetadata(w http.ResponseWriter, r *http.Reques
7373
JSONError(w, http.StatusInternalServerError, "failed to create request")
7474
return
7575
}
76-
req.Header.Set("Accept", "application/json")
76+
// Use abbreviated metadata when cooldown is disabled — it's much smaller
77+
// (e.g. drizzle-orm: 4MB vs 92MB) but lacks the time map needed for cooldown.
78+
if h.proxy.Cooldown != nil && h.proxy.Cooldown.Enabled() {
79+
req.Header.Set("Accept", "application/json")
80+
} else {
81+
req.Header.Set("Accept", "application/vnd.npm.install-v1+json")
82+
}
7783

7884
resp, err := h.proxy.HTTPClient.Do(req)
7985
if err != nil {

internal/handler/npm_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,62 @@ func TestNPMRewriteMetadataCooldownExemptPackage(t *testing.T) {
293293
}
294294
}
295295

296+
func TestNPMHandlerUsesAbbreviatedMetadata(t *testing.T) {
297+
var gotAccept string
298+
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
299+
gotAccept = r.Header.Get("Accept")
300+
w.Header().Set("Content-Type", "application/json")
301+
_, _ = w.Write([]byte(`{
302+
"name": "testpkg",
303+
"versions": {
304+
"1.0.0": {
305+
"name": "testpkg",
306+
"version": "1.0.0",
307+
"dist": {
308+
"tarball": "https://registry.npmjs.org/testpkg/-/testpkg-1.0.0.tgz"
309+
}
310+
}
311+
}
312+
}`))
313+
}))
314+
defer upstream.Close()
315+
316+
t.Run("no cooldown uses abbreviated metadata", func(t *testing.T) {
317+
h := &NPMHandler{
318+
proxy: testProxy(),
319+
upstreamURL: upstream.URL,
320+
proxyURL: "http://proxy.local",
321+
}
322+
323+
req := httptest.NewRequest(http.MethodGet, "/testpkg", nil)
324+
w := httptest.NewRecorder()
325+
h.handlePackageMetadata(w, req)
326+
327+
if gotAccept != "application/vnd.npm.install-v1+json" {
328+
t.Errorf("Accept = %q, want abbreviated metadata header", gotAccept)
329+
}
330+
})
331+
332+
t.Run("cooldown enabled uses full metadata", func(t *testing.T) {
333+
proxy := testProxy()
334+
proxy.Cooldown = &cooldown.Config{Default: "3d"}
335+
336+
h := &NPMHandler{
337+
proxy: proxy,
338+
upstreamURL: upstream.URL,
339+
proxyURL: "http://proxy.local",
340+
}
341+
342+
req := httptest.NewRequest(http.MethodGet, "/testpkg", nil)
343+
w := httptest.NewRecorder()
344+
h.handlePackageMetadata(w, req)
345+
346+
if gotAccept == "application/vnd.npm.install-v1+json" {
347+
t.Error("cooldown enabled should use full metadata, not abbreviated")
348+
}
349+
})
350+
}
351+
296352
func TestNPMHandlerMetadataNotFound(t *testing.T) {
297353
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
298354
w.WriteHeader(http.StatusNotFound)

internal/handler/read_metadata_test.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package handler
22

33
import (
44
"bytes"
5+
"errors"
56
"testing"
67
)
78

@@ -17,9 +18,8 @@ func TestReadMetadata(t *testing.T) {
1718
}
1819
})
1920

20-
t.Run("truncates at limit", func(t *testing.T) {
21-
// Create a reader slightly larger than maxMetadataSize
22-
data := make([]byte, maxMetadataSize+100)
21+
t.Run("exactly at limit", func(t *testing.T) {
22+
data := make([]byte, maxMetadataSize)
2323
for i := range data {
2424
data[i] = 'x'
2525
}
@@ -31,4 +31,15 @@ func TestReadMetadata(t *testing.T) {
3131
t.Errorf("got length %d, want %d", len(got), maxMetadataSize)
3232
}
3333
})
34+
35+
t.Run("over limit returns error", func(t *testing.T) {
36+
data := make([]byte, maxMetadataSize+100)
37+
for i := range data {
38+
data[i] = 'x'
39+
}
40+
_, err := ReadMetadata(bytes.NewReader(data))
41+
if !errors.Is(err, ErrMetadataTooLarge) {
42+
t.Errorf("got error %v, want ErrMetadataTooLarge", err)
43+
}
44+
})
3445
}

0 commit comments

Comments
 (0)