From 321896996f9b26bf115e03f6dc9402817835ce4e Mon Sep 17 00:00:00 2001 From: Sahil Bansal Date: Tue, 26 May 2026 20:50:10 +0530 Subject: [PATCH] fix: skip PyPI dependency cooldown for clients without PEP 691 support (#295) PMG was forcing Accept: application/vnd.pypi.simple.v1+json on all Simple API requests regardless of client capability. Older pip versions (< 22.3) that only understand text/html would reject the JSON response, breaking installs entirely. Now checks the client's original Accept header before applying cooldown. If PEP 691 is not supported, the request passes through unchanged with a warning log recommending pip upgrade. --- README.md | 3 +- proxy/interceptors/pypi_cooldown.go | 13 ++++++++ proxy/interceptors/pypi_cooldown_test.go | 39 ++++++++++++++++++++++-- 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f47d1d0..f6384ad 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,8 @@ Recent compromises in popular ecosystems: - [**telnyx 4.87.2**](https://safedep.io/malicious-telnyx-pypi-compromise/) - a legitimate telecom SDK hijacked on PyPI - [**pino-sdk-v2**](https://safedep.io/malicious-npm-package-pino-sdk-v2-env-exfiltration/) - a typosquat package disguised as the popular pino logger -PMG intercepts every package install and checks it for malware **before** code executes. Install it once, and PMG covers every `npm install`, `pip install`, and `poetry add` after that. + +**PMG is free, open source (Apache 2.0), and requires no account or API key.** It intercepts every package install and checks it against [SafeDep's free community API](https://safedep.io) for known malware **before** code executes. Install it once, and it covers every `npm install`, `pip install`, and `poetry add` after that. > Featured in [tl;dr sec](https://tldrsec.com/p/tldr-sec-316). diff --git a/proxy/interceptors/pypi_cooldown.go b/proxy/interceptors/pypi_cooldown.go index b960222..8ce0daa 100644 --- a/proxy/interceptors/pypi_cooldown.go +++ b/proxy/interceptors/pypi_cooldown.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "time" packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1" @@ -26,9 +27,21 @@ func newPypiCooldownHandler(statsCollector *AnalysisStatsCollector) *pypiCooldow // HandleMetadataRequest overrides the Accept header to force a PEP 691 JSON response, // then registers a response modifier that strips files for versions within the cooldown window. +// If the client does not support PEP 691 (pip < 22.3), cooldown is skipped to avoid +// returning a content type the client cannot parse. func (h *pypiCooldownHandler) HandleMetadataRequest(ctx *proxy.RequestContext, packageName string, cooldownDays int, pinnedVersion string) (*proxy.InterceptorResponse, error) { log.Debugf("[%s] Cooldown: registering metadata modifier for %s", ctx.RequestID, packageName) + originalAccept := ctx.Headers.Get("Accept") + clientSupportsPEP691 := strings.Contains(originalAccept, pypiSimpleAPIContentType) + + if !clientSupportsPEP691 { + log.Warnf("[%s] Cooldown: client does not support PEP 691 JSON (Accept: %s), "+ + "cooldown cannot be enforced for %s. Upgrade pip to 22.3+ for cooldown support.", + ctx.RequestID, originalAccept, packageName) + return &proxy.InterceptorResponse{Action: proxy.ActionAllow}, nil + } + // Force PEP 691 JSON so we receive upload-time per file entry. ctx.Headers.Set("Accept", pypiSimpleAPIContentType) // Prevent compression so the response body can be parsed as JSON directly. diff --git a/proxy/interceptors/pypi_cooldown_test.go b/proxy/interceptors/pypi_cooldown_test.go index 56a2938..301c48f 100644 --- a/proxy/interceptors/pypi_cooldown_test.go +++ b/proxy/interceptors/pypi_cooldown_test.go @@ -352,7 +352,7 @@ func TestStripCooldownFiles_UnparseableFilename_KeepFile(t *testing.T) { func TestPyPICooldown_HandleMetadataRequest_OverridesHeaders(t *testing.T) { handler := newPypiCooldownHandler(nil) ctx := makeTestRequestContext("https://pypi.org/simple/requests/") - ctx.Headers.Set("Accept", "text/html") + ctx.Headers.Set("Accept", "application/vnd.pypi.simple.v1+json, text/html;q=0.01") ctx.Headers.Set("Accept-Encoding", "gzip") ctx.Headers.Set("If-None-Match", `"abc123"`) ctx.Headers.Set("If-Modified-Since", "Wed, 01 Jan 2025 00:00:00 GMT") @@ -366,9 +366,22 @@ func TestPyPICooldown_HandleMetadataRequest_OverridesHeaders(t *testing.T) { assert.Empty(t, ctx.Headers.Get("If-Modified-Since")) } +func TestPyPICooldown_HandleMetadataRequest_ClientWithoutPEP691_SkipsCooldown(t *testing.T) { + handler := newPypiCooldownHandler(nil) + ctx := makeTestRequestContext("https://pypi.org/simple/requests/") + ctx.Headers.Set("Accept", "text/html") + + resp, err := handler.HandleMetadataRequest(ctx, "requests", 5, "") + require.NoError(t, err) + assert.Equal(t, proxy.ActionAllow, resp.Action) + assert.Nil(t, resp.ResponseModifier) + assert.Equal(t, "text/html", ctx.Headers.Get("Accept")) +} + func TestPyPICooldown_HandleMetadataRequest_NonJSONResponse_FailOpen(t *testing.T) { handler := newPypiCooldownHandler(nil) ctx := makeTestRequestContext("https://pypi.org/simple/requests/") + ctx.Headers.Set("Accept", pypiSimpleAPIContentType) resp, err := handler.HandleMetadataRequest(ctx, "requests", 5, "") require.NoError(t, err) @@ -395,6 +408,7 @@ func TestPyPICooldown_HandleMetadataRequest_StripsRecentVersions(t *testing.T) { handler := newPypiCooldownHandler(NewAnalysisStatsCollector()) ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/") + ctx.Headers.Set("Accept", pypiSimpleAPIContentType) resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "") require.NoError(t, err) @@ -432,6 +446,7 @@ func TestPyPICooldown_HandleMetadataRequest_AllVersionsInCooldown_RecordsStats(t collector := NewAnalysisStatsCollector() handler := newPypiCooldownHandler(collector) ctx := makeTestRequestContext("https://pypi.org/simple/newpkg/") + ctx.Headers.Set("Accept", pypiSimpleAPIContentType) resp, err := handler.HandleMetadataRequest(ctx, "newpkg", 5, "") require.NoError(t, err) @@ -465,6 +480,7 @@ func TestPyPICooldown_HandleMetadataRequest_NoVersionsInCooldown_BodyUnchanged(t handler := newPypiCooldownHandler(nil) ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/") + ctx.Headers.Set("Accept", pypiSimpleAPIContentType) resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "") require.NoError(t, err) @@ -481,6 +497,7 @@ func TestPyPICooldown_HandleMetadataRequest_NoVersionsInCooldown_BodyUnchanged(t func TestPyPICooldown_HandleMetadataRequest_MalformedJSON_FailOpen(t *testing.T) { handler := newPypiCooldownHandler(nil) ctx := makeTestRequestContext("https://pypi.org/simple/badpkg/") + ctx.Headers.Set("Accept", pypiSimpleAPIContentType) resp, err := handler.HandleMetadataRequest(ctx, "badpkg", 5, "") require.NoError(t, err) @@ -507,6 +524,7 @@ func TestPyPICooldown_HandleMetadataRequest_PinnedVersionInCooldown_RecordsStats collector := NewAnalysisStatsCollector() handler := newPypiCooldownHandler(collector) ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/") + ctx.Headers.Set("Accept", pypiSimpleAPIContentType) resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "2.0.0") require.NoError(t, err) @@ -541,6 +559,7 @@ func TestPyPICooldown_HandleMetadataRequest_PinnedVersionNotInCooldown_NoBlock(t collector := NewAnalysisStatsCollector() handler := newPypiCooldownHandler(collector) ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/") + ctx.Headers.Set("Accept", pypiSimpleAPIContentType) resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "1.0.0") require.NoError(t, err) @@ -568,6 +587,7 @@ func TestPyPICooldown_HandleMetadataRequest_UnpinnedWithRemainingVersions_NoBloc collector := NewAnalysisStatsCollector() handler := newPypiCooldownHandler(collector) ctx := makeTestRequestContext("https://pypi.org/simple/testpkg/") + ctx.Headers.Set("Accept", pypiSimpleAPIContentType) resp, err := handler.HandleMetadataRequest(ctx, "testpkg", 5, "") require.NoError(t, err) @@ -590,7 +610,7 @@ func TestPyPICooldown_InterceptorDelegation_CooldownEnabled(t *testing.T) { ctx := makeTestRequestContext("https://pypi.org/simple/requests/") ctx.Hostname = "pypi.org" - ctx.Headers.Set("Accept", "text/html") + ctx.Headers.Set("Accept", "application/vnd.pypi.simple.v1+json, text/html;q=0.01") resp, err := interceptor.HandleRequest(ctx) require.NoError(t, err) @@ -598,6 +618,21 @@ func TestPyPICooldown_InterceptorDelegation_CooldownEnabled(t *testing.T) { assert.Equal(t, "application/vnd.pypi.simple.v1+json", ctx.Headers.Get("Accept")) } +func TestPyPICooldown_InterceptorDelegation_CooldownEnabled_OldPip(t *testing.T) { + setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: true, Days: 5}) + + interceptor := NewPypiRegistryInterceptor(nil, NewInMemoryAnalysisCache(), NewAnalysisStatsCollector(), make(chan *ConfirmationRequest, 1), InterceptorContext{}) + + ctx := makeTestRequestContext("https://pypi.org/simple/requests/") + ctx.Hostname = "pypi.org" + ctx.Headers.Set("Accept", "text/html") + + resp, err := interceptor.HandleRequest(ctx) + require.NoError(t, err) + assert.Equal(t, proxy.ActionAllow, resp.Action) + assert.Equal(t, "text/html", ctx.Headers.Get("Accept")) +} + func TestPyPICooldown_InterceptorDelegation_CooldownDisabled(t *testing.T) { setCooldownConfig(t, config.DependencyCooldownConfig{Enabled: false, Days: 5})