From f27fe1193d35eae6c833ec7d02bdf2679b83ead6 Mon Sep 17 00:00:00 2001 From: Simon Chrzanowski Date: Sat, 24 Oct 2026 07:59:32 +0200 Subject: [PATCH] Refuse overlong gem filenames parseGemFilename tries every "-" in the filename as the start of the version and matches the rest against two patterns each time, so its cost grows with the square of the filename's length. A path of a few hundred kilobytes kept a request busy for minutes, and the parse doesn't watch the request context. Gem filenames longer than 512 bytes are now answered with 400 before parsing. Real ones are far shorter. --- internal/handler/gem.go | 10 ++++++++++ internal/handler/gem_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/internal/handler/gem.go b/internal/handler/gem.go index e1acb129..ce755105 100644 --- a/internal/handler/gem.go +++ b/internal/handler/gem.go @@ -99,7 +99,13 @@ func (h *GemHandler) handleDownload(w http.ResponseWriter, r *http.Request) { // starts at the first "-" that leaves such a version and platform, // so a name with a "-" segment of its own, such as devise-2fa, is // kept whole. A filename no split fits is split at the first "-". +// +// Trying every "-" costs time quadratic in the filename's length, so +// a filename longer than maxGemFilenameLength is refused. func (h *GemHandler) parseGemFilename(filename string) (name, version string) { + if len(filename) > maxGemFilenameLength { + return "", "" + } base := strings.TrimSuffix(filename, ".gem") first := -1 for i := 0; i+1 < len(base); i++ { @@ -119,6 +125,10 @@ func (h *GemHandler) parseGemFilename(filename string) (name, version string) { return base[:first], base[first+1:] } +// maxGemFilenameLength bounds the gem filenames parseGemFilename accepts. +// Real gem filenames are a small fraction of this. +const maxGemFilenameLength = 512 + var ( gemVersionPattern = regexp.MustCompile(`^[0-9]+(\.[0-9A-Za-z]+)*$`) gemPlatformPattern = regexp.MustCompile(`^[A-Za-z][0-9A-Za-z_]*(-[0-9A-Za-z_]+)*$`) diff --git a/internal/handler/gem_test.go b/internal/handler/gem_test.go index 9b710748..f4ec9eee 100644 --- a/internal/handler/gem_test.go +++ b/internal/handler/gem_test.go @@ -49,6 +49,31 @@ func TestGemParseFilename(t *testing.T) { } } +func TestGemDownloadRefusesLongFilename(t *testing.T) { + // Every "-1-" could start a version, and each try reads to the "!" at + // the end before failing, so parsing this used to take minutes. + filename := strings.Repeat("-1-aaaaaa", 100_000) + "!.gem" + + h := &GemHandler{proxy: testProxy(), upstreamURL: "http://upstream.invalid"} + req := httptest.NewRequest(http.MethodGet, "/gems/x", nil) + req.SetPathValue("filename", "x"+filename) + w := httptest.NewRecorder() + + done := make(chan struct{}) + go func() { + h.handleDownload(w, req) + close(done) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("parsing a long gem filename did not finish within 5s") + } + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest) + } +} + func TestGemCompactIndexCooldown(t *testing.T) { now := time.Now() oldTime := now.Add(-7 * 24 * time.Hour).Format(time.RFC3339)