diff --git a/internal/handler/composer.go b/internal/handler/composer.go index bdeceae..d7bbc5d 100644 --- a/internal/handler/composer.go +++ b/internal/handler/composer.go @@ -108,7 +108,7 @@ func (h *ComposerHandler) handlePackageMetadata(w http.ResponseWriter, r *http.R return } - body, err := io.ReadAll(resp.Body) + body, err := ReadMetadata(resp.Body) if err != nil { http.Error(w, "failed to read response", http.StatusInternalServerError) return diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 9cf6e2d..3bd49f8 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -30,6 +30,17 @@ func containsPathTraversal(path string) bool { return false } +// maxMetadataSize is the maximum size of upstream metadata responses (50 MB). +// Package metadata (e.g. npm with many versions) can be large, but unbounded +// reads risk OOM if an upstream misbehaves. +const maxMetadataSize = 50 << 20 + +// ReadMetadata reads an upstream response body with a size limit to prevent OOM +// from unexpectedly large responses. +func ReadMetadata(r io.Reader) ([]byte, error) { + return io.ReadAll(io.LimitReader(r, maxMetadataSize)) +} + // Proxy provides shared functionality for protocol handlers. type Proxy struct { DB *database.DB diff --git a/internal/handler/npm.go b/internal/handler/npm.go index 4468d1b..fc23927 100644 --- a/internal/handler/npm.go +++ b/internal/handler/npm.go @@ -3,7 +3,6 @@ package handler import ( "encoding/json" "fmt" - "io" "net/http" "net/url" "sort" @@ -93,7 +92,7 @@ func (h *NPMHandler) handlePackageMetadata(w http.ResponseWriter, r *http.Reques } // Parse and rewrite tarball URLs - body, err := io.ReadAll(resp.Body) + body, err := ReadMetadata(resp.Body) if err != nil { JSONError(w, http.StatusInternalServerError, "failed to read response") return diff --git a/internal/handler/nuget.go b/internal/handler/nuget.go index 1b0360a..fafdebb 100644 --- a/internal/handler/nuget.go +++ b/internal/handler/nuget.go @@ -77,7 +77,7 @@ func (h *NuGetHandler) handleServiceIndex(w http.ResponseWriter, r *http.Request return } - body, err := io.ReadAll(resp.Body) + body, err := ReadMetadata(resp.Body) if err != nil { http.Error(w, "failed to read response", http.StatusInternalServerError) return diff --git a/internal/handler/pub.go b/internal/handler/pub.go index 9ae0b70..efb4211 100644 --- a/internal/handler/pub.go +++ b/internal/handler/pub.go @@ -109,7 +109,7 @@ func (h *PubHandler) handlePackageMetadata(w http.ResponseWriter, r *http.Reques return } - body, err := io.ReadAll(resp.Body) + body, err := ReadMetadata(resp.Body) if err != nil { http.Error(w, "failed to read response", http.StatusInternalServerError) return diff --git a/internal/handler/pypi.go b/internal/handler/pypi.go index f1ec582..a4deb7d 100644 --- a/internal/handler/pypi.go +++ b/internal/handler/pypi.go @@ -92,7 +92,7 @@ func (h *PyPIHandler) handleSimplePackage(w http.ResponseWriter, r *http.Request return } - body, err := io.ReadAll(resp.Body) + body, err := ReadMetadata(resp.Body) if err != nil { http.Error(w, "failed to read response", http.StatusInternalServerError) return @@ -259,7 +259,7 @@ func (h *PyPIHandler) proxyAndRewriteJSON(w http.ResponseWriter, r *http.Request return } - body, err := io.ReadAll(resp.Body) + body, err := ReadMetadata(resp.Body) if err != nil { http.Error(w, "failed to read response", http.StatusInternalServerError) return diff --git a/internal/handler/read_metadata_test.go b/internal/handler/read_metadata_test.go new file mode 100644 index 0000000..e1ed192 --- /dev/null +++ b/internal/handler/read_metadata_test.go @@ -0,0 +1,34 @@ +package handler + +import ( + "bytes" + "testing" +) + +func TestReadMetadata(t *testing.T) { + t.Run("small body", func(t *testing.T) { + data := []byte("hello world") + got, err := ReadMetadata(bytes.NewReader(data)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bytes.Equal(got, data) { + t.Errorf("got %q, want %q", got, data) + } + }) + + t.Run("truncates at limit", func(t *testing.T) { + // Create a reader slightly larger than maxMetadataSize + data := make([]byte, maxMetadataSize+100) + for i := range data { + data[i] = 'x' + } + got, err := ReadMetadata(bytes.NewReader(data)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != int(maxMetadataSize) { + t.Errorf("got length %d, want %d", len(got), maxMetadataSize) + } + }) +} diff --git a/internal/server/api.go b/internal/server/api.go index a46e0aa..f756cba 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -327,6 +327,7 @@ func (h *APIHandler) HandleGetVulns(w http.ResponseWriter, r *http.Request) { // @Failure 500 {string} string // @Router /api/outdated [post] func (h *APIHandler) HandleOutdated(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB var req OutdatedRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) @@ -372,6 +373,7 @@ func (h *APIHandler) HandleOutdated(w http.ResponseWriter, r *http.Request) { // @Failure 500 {string} string // @Router /api/bulk [post] func (h *APIHandler) HandleBulkLookup(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB var req BulkRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) diff --git a/internal/server/api_test.go b/internal/server/api_test.go index 832f31a..b11983e 100644 --- a/internal/server/api_test.go +++ b/internal/server/api_test.go @@ -94,6 +94,25 @@ func TestHandleOutdated_EmptyBody(t *testing.T) { } } +func TestHandleOutdated_OversizedBody(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + svc := enrichment.New(logger) + h := NewAPIHandler(svc, nil) + + // Send a body larger than 1 MB + body := make([]byte, 2<<20) + for i := range body { + body[i] = 'x' + } + req := httptest.NewRequest("POST", "/api/outdated", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.HandleOutdated(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status %d for oversized body, got %d", http.StatusBadRequest, w.Code) + } +} + func TestHandleOutdated_InvalidJSON(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) svc := enrichment.New(logger)