Fail closed in gem, Hex and PyPI cooldown
RubyGems, Hex and PyPI keep publish times outside the metadata clients
read, so cooldown fetches them from a second API. When that fetch
failed, was rate limited, or returned something that didn't parse, the
compact index, Hex package or simple page was served unfiltered, and a
Hex package that couldn't be re-encoded was passed on as upstream sent
it. A flaky or rate-limited timestamp API let every version in
cooldown through.
These now fail with 502, or with 503 and Retry-After when the rate
limit gate held the request, as the Go handler already does. PyPI file
downloads check the same way. A PyPI package the JSON API answers 404
for still has no times and is served unfiltered, as before.
npm, Composer, pub.dev and PyPI JSON metadata that couldn't be
rewritten was also passed on unfiltered unless the package had denied
versions. It now fails with 502 whenever cooldown applies too. npm
tarball downloads whose packument couldn't be fetched skipped the
cooldown check and downloaded straight from upstream; they now fail.
All of this only applies when cooldown covers the package: packages
exempted with a duration of 0 skip the timestamp lookups, and the gem
and Hex handlers serve them through the metadata cache as when cooldown
is off.
2026-10-24 08:11:25 +02:00
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"io"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/git-pkgs/cooldown"
|
|
|
|
|
registryclient "github.com/git-pkgs/registries/client"
|
|
|
|
|
"github.com/git-pkgs/registries/fetch"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// holdPaths returns a client that answers requests for the given paths with
|
|
|
|
|
// the rate limit gate's HeldError and sends the rest to upstream.
|
|
|
|
|
func holdPaths(paths ...string) *http.Client {
|
|
|
|
|
held := ®istryclient.HeldError{Host: "upstream.example", Until: time.Now().Add(30 * time.Second), RetryAfter: 30 * time.Second}
|
|
|
|
|
return &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
|
|
|
|
for _, p := range paths {
|
|
|
|
|
if r.URL.Path == p {
|
|
|
|
|
return nil, held
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return http.DefaultTransport.RoundTrip(r)
|
|
|
|
|
})}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func serveGet(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
|
|
|
|
|
t.Helper()
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
|
|
|
|
return w
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func wantStatus(t *testing.T, w *httptest.ResponseRecorder, status int, leaked string) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
if w.Code != status {
|
|
|
|
|
t.Errorf("status = %d, want %d: %s", w.Code, status, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if status == http.StatusServiceUnavailable && w.Header().Get("Retry-After") == "" {
|
|
|
|
|
t.Error("503 without Retry-After")
|
|
|
|
|
}
|
|
|
|
|
if leaked != "" && strings.Contains(w.Body.String(), leaked) {
|
|
|
|
|
t.Errorf("response lists %q, which cooldown should have withheld", leaked)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGemCooldownFailsClosed(t *testing.T) {
|
|
|
|
|
index := "---\n1.0.0 |checksum:abc\n2.0.0 |checksum:def\n"
|
|
|
|
|
versionsStatus := http.StatusInternalServerError
|
|
|
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if strings.HasPrefix(r.URL.Path, "/api/v1/versions/") {
|
|
|
|
|
w.WriteHeader(versionsStatus)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
_, _ = io.WriteString(w, index)
|
|
|
|
|
}))
|
|
|
|
|
defer upstream.Close()
|
|
|
|
|
|
|
|
|
|
t.Run("versions API fails", func(t *testing.T) {
|
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
|
|
|
proxy.Cooldown = &cooldown.Config{Default: "3d"}
|
|
|
|
|
h := NewGemHandlerWithUpstream(proxy, "http://proxy.test", upstream.URL)
|
|
|
|
|
wantStatus(t, serveGet(t, h.Routes(), "/info/testgem"), http.StatusBadGateway, "2.0.0")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("versions API held by rate limit", func(t *testing.T) {
|
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
|
|
|
proxy.Cooldown = &cooldown.Config{Default: "3d"}
|
|
|
|
|
proxy.HTTPClient = holdPaths("/api/v1/versions/testgem.json")
|
|
|
|
|
h := NewGemHandlerWithUpstream(proxy, "http://proxy.test", upstream.URL)
|
|
|
|
|
wantStatus(t, serveGet(t, h.Routes(), "/info/testgem"), http.StatusServiceUnavailable, "2.0.0")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("exempt package needs no times", func(t *testing.T) {
|
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
|
|
|
proxy.Cooldown = &cooldown.Config{Default: "3d", Packages: map[string]string{"pkg:gem/testgem": "0"}}
|
|
|
|
|
h := NewGemHandlerWithUpstream(proxy, "http://proxy.test", upstream.URL)
|
|
|
|
|
w := serveGet(t, h.Routes(), "/info/testgem")
|
|
|
|
|
if w.Code != http.StatusOK || w.Body.String() != index {
|
|
|
|
|
t.Errorf("status = %d, body = %q; want the index unchanged", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHexCooldownFailsClosed(t *testing.T) {
|
|
|
|
|
now := time.Now()
|
|
|
|
|
proto := buildHexSigned(buildHexPackage("testpkg", []string{testVersion100, "2.0.0"}))
|
|
|
|
|
apiJSON, _ := json.Marshal(hexPackageAPI{Releases: []hexRelease{
|
|
|
|
|
{Version: testVersion100, InsertedAt: now.Add(-7 * 24 * time.Hour).Format(time.RFC3339Nano)},
|
|
|
|
|
{Version: "2.0.0", InsertedAt: now.Add(-time.Hour).Format(time.RFC3339Nano)},
|
|
|
|
|
}})
|
|
|
|
|
|
|
|
|
|
newUpstream := func(t *testing.T, packageBody []byte, apiStatus int) *httptest.Server {
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
switch r.URL.Path {
|
|
|
|
|
case "/packages/testpkg":
|
|
|
|
|
_, _ = w.Write(packageBody)
|
|
|
|
|
case "/api/packages/testpkg":
|
|
|
|
|
w.WriteHeader(apiStatus)
|
|
|
|
|
_, _ = w.Write(apiJSON)
|
|
|
|
|
default:
|
|
|
|
|
w.WriteHeader(http.StatusNotFound)
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
t.Cleanup(srv.Close)
|
|
|
|
|
return srv
|
|
|
|
|
}
|
|
|
|
|
handler := func(t *testing.T, srv *httptest.Server, client *http.Client) http.Handler {
|
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
|
|
|
proxy.Cooldown = &cooldown.Config{Default: "3d"}
|
|
|
|
|
if client != nil {
|
|
|
|
|
proxy.HTTPClient = client
|
|
|
|
|
}
|
|
|
|
|
return NewHexHandlerWithUpstreams(proxy, "http://proxy.test", srv.URL, srv.URL).Routes()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
t.Run("API fails", func(t *testing.T) {
|
|
|
|
|
srv := newUpstream(t, proto, http.StatusInternalServerError)
|
|
|
|
|
wantStatus(t, serveGet(t, handler(t, srv, nil), "/packages/testpkg"), http.StatusBadGateway, "")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("API held by rate limit", func(t *testing.T) {
|
|
|
|
|
srv := newUpstream(t, proto, http.StatusOK)
|
|
|
|
|
w := serveGet(t, handler(t, srv, holdPaths("/api/packages/testpkg")), "/packages/testpkg")
|
|
|
|
|
wantStatus(t, w, http.StatusServiceUnavailable, "")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("package can't be filtered", func(t *testing.T) {
|
|
|
|
|
srv := newUpstream(t, []byte("not gzip 2.0.0"), http.StatusOK)
|
|
|
|
|
wantStatus(t, serveGet(t, handler(t, srv, nil), "/packages/testpkg"), http.StatusBadGateway, "2.0.0")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("filtered", func(t *testing.T) {
|
|
|
|
|
srv := newUpstream(t, proto, http.StatusOK)
|
|
|
|
|
w := serveGet(t, handler(t, srv, nil), "/packages/testpkg")
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("status = %d, want 200", w.Code)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestPyPICooldownFailsClosed(t *testing.T) {
|
|
|
|
|
simple := `{"meta":{"api-version":"1.4"},"name":"ruff","files":[` +
|
|
|
|
|
`{"filename":"ruff-1.0.0.tar.gz","url":"https://files.pythonhosted.org/packages/ab/ruff-1.0.0.tar.gz"},` +
|
|
|
|
|
`{"filename":"ruff-2.0.0.tar.gz","url":"https://files.pythonhosted.org/packages/cd/ruff-2.0.0.tar.gz"}]}`
|
|
|
|
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
json func(*http.Request) (*http.Response, error)
|
|
|
|
|
status int
|
|
|
|
|
}{
|
|
|
|
|
{"JSON API fails", func(r *http.Request) (*http.Response, error) {
|
|
|
|
|
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody, Request: r}, nil
|
|
|
|
|
}, http.StatusBadGateway},
|
|
|
|
|
{"JSON API held by rate limit", func(*http.Request) (*http.Response, error) {
|
|
|
|
|
return nil, ®istryclient.HeldError{Host: "pypi.test", RetryAfter: 30 * time.Second}
|
|
|
|
|
}, http.StatusServiceUnavailable},
|
|
|
|
|
{"JSON has no releases", func(r *http.Request) (*http.Response, error) {
|
|
|
|
|
return pypiHTTPResponse(r, "application/json", `{"info":{}}`), nil
|
|
|
|
|
}, http.StatusBadGateway},
|
|
|
|
|
{"JSON API doesn't know the package", func(r *http.Request) (*http.Response, error) {
|
|
|
|
|
return &http.Response{StatusCode: http.StatusNotFound, Body: http.NoBody, Request: r}, nil
|
|
|
|
|
}, http.StatusOK},
|
|
|
|
|
}
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
h, proxy := setupPyPIHandler(t, func(r *http.Request) (*http.Response, error) {
|
|
|
|
|
if r.URL.Path == "/pypi/ruff/json" {
|
|
|
|
|
return tt.json(r)
|
|
|
|
|
}
|
|
|
|
|
return pypiHTTPResponse(r, pypiSimpleJSON, simple), nil
|
|
|
|
|
})
|
|
|
|
|
proxy.Cooldown = &cooldown.Config{Default: "7d"}
|
|
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/simple/ruff/", nil)
|
|
|
|
|
req.Header.Set("Accept", uvPyPIAccept)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
h.Routes().ServeHTTP(w, req)
|
|
|
|
|
leaked := "ruff-2.0.0"
|
|
|
|
|
if tt.status == http.StatusOK {
|
|
|
|
|
leaked = ""
|
|
|
|
|
}
|
|
|
|
|
wantStatus(t, w, tt.status, leaked)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestPyPIDownloadCooldownFailsClosed(t *testing.T) {
|
|
|
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
|
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
|
|
|
}))
|
|
|
|
|
defer upstream.Close()
|
|
|
|
|
|
|
|
|
|
proxy, _, _, fetcher := setupTestProxy(t)
|
|
|
|
|
proxy.Cooldown = &cooldown.Config{Default: "7d"}
|
|
|
|
|
fetcher.artifact = &fetch.Artifact{Body: io.NopCloser(strings.NewReader("sdist")), ContentType: "application/octet-stream"}
|
|
|
|
|
h := &PyPIHandler{proxy: proxy, upstreamURL: upstream.URL, proxyURL: "http://localhost"}
|
|
|
|
|
|
|
|
|
|
w := serveGet(t, h.Routes(), "/packages/packages/ab/cd/ef0123456789/newpkg-2.0.0.tar.gz")
|
|
|
|
|
wantStatus(t, w, http.StatusBadGateway, "")
|
|
|
|
|
if fetcher.fetchCalled {
|
|
|
|
|
t.Error("fetched a file whose publish time couldn't be checked")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestNPMDownloadCooldownFailsClosed(t *testing.T) {
|
|
|
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
|
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
|
|
|
}))
|
|
|
|
|
defer upstream.Close()
|
|
|
|
|
|
|
|
|
|
proxy, _, _, fetcher := setupTestProxy(t)
|
|
|
|
|
proxy.Cooldown = &cooldown.Config{Default: "7d"}
|
|
|
|
|
fetcher.artifact = &fetch.Artifact{Body: io.NopCloser(strings.NewReader("tarball")), ContentType: "application/octet-stream"}
|
|
|
|
|
h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL)
|
|
|
|
|
|
|
|
|
|
w := serveGet(t, h.Routes(), "/leftpad/-/leftpad-2.0.0.tgz")
|
|
|
|
|
wantStatus(t, w, http.StatusBadGateway, "")
|
|
|
|
|
if fetcher.fetchCalled {
|
|
|
|
|
t.Error("fetched a tarball whose publish time couldn't be checked")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-25 12:31:33 +01:00
|
|
|
// TestDownloadCooldownErrorServesCachedFile checks that a file already in the
|
|
|
|
|
// cache is still served when its publish time can't be read, while an uncached
|
|
|
|
|
// one fails.
|
|
|
|
|
func TestDownloadCooldownErrorServesCachedFile(t *testing.T) {
|
|
|
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
|
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
|
|
|
}))
|
|
|
|
|
defer upstream.Close()
|
|
|
|
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
ecosystem, name, version, filename, path string
|
|
|
|
|
handler func(*Proxy) http.Handler
|
|
|
|
|
}{
|
|
|
|
|
{"pypi", "newpkg", "2.0.0", "newpkg-2.0.0.tar.gz", "/packages/packages/ab/cd/ef0123456789/newpkg-2.0.0.tar.gz",
|
|
|
|
|
func(p *Proxy) http.Handler {
|
|
|
|
|
return (&PyPIHandler{proxy: p, upstreamURL: upstream.URL, downloadURL: upstream.URL, proxyURL: "http://localhost"}).Routes()
|
|
|
|
|
}},
|
|
|
|
|
{"npm", "leftpad", "2.0.0", "leftpad-2.0.0.tgz", "/leftpad/-/leftpad-2.0.0.tgz",
|
|
|
|
|
func(p *Proxy) http.Handler { return NewNPMHandler(p, "http://proxy.test", upstream.URL).Routes() }},
|
|
|
|
|
}
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.ecosystem, func(t *testing.T) {
|
|
|
|
|
proxy, db, store, fetcher := setupTestProxy(t)
|
|
|
|
|
proxy.Cooldown = &cooldown.Config{Default: "7d"}
|
|
|
|
|
h := tt.handler(proxy)
|
|
|
|
|
|
|
|
|
|
seedPackage(t, db, store, tt.ecosystem, tt.name, tt.version, tt.filename, "cached file")
|
|
|
|
|
w := serveGet(t, h, tt.path)
|
|
|
|
|
wantStatus(t, w, http.StatusOK, "")
|
|
|
|
|
if w.Body.String() != "cached file" {
|
|
|
|
|
t.Errorf("body = %q, want the cached file", w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
other := strings.ReplaceAll(tt.path, "2.0.0", "3.0.0")
|
|
|
|
|
wantStatus(t, serveGet(t, h, other), http.StatusBadGateway, "")
|
|
|
|
|
if fetcher.fetchCalled {
|
|
|
|
|
t.Error("fetched a file whose publish time couldn't be checked")
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Fail closed in gem, Hex and PyPI cooldown
RubyGems, Hex and PyPI keep publish times outside the metadata clients
read, so cooldown fetches them from a second API. When that fetch
failed, was rate limited, or returned something that didn't parse, the
compact index, Hex package or simple page was served unfiltered, and a
Hex package that couldn't be re-encoded was passed on as upstream sent
it. A flaky or rate-limited timestamp API let every version in
cooldown through.
These now fail with 502, or with 503 and Retry-After when the rate
limit gate held the request, as the Go handler already does. PyPI file
downloads check the same way. A PyPI package the JSON API answers 404
for still has no times and is served unfiltered, as before.
npm, Composer, pub.dev and PyPI JSON metadata that couldn't be
rewritten was also passed on unfiltered unless the package had denied
versions. It now fails with 502 whenever cooldown applies too. npm
tarball downloads whose packument couldn't be fetched skipped the
cooldown check and downloaded straight from upstream; they now fail.
All of this only applies when cooldown covers the package: packages
exempted with a duration of 0 skip the timestamp lookups, and the gem
and Hex handlers serve them through the metadata cache as when cooldown
is off.
2026-10-24 08:11:25 +02:00
|
|
|
// TestCooldownRewriteFailureFailsClosed checks that a metadata document the
|
|
|
|
|
// cooldown rewrite can't parse is refused rather than passed on, and still
|
|
|
|
|
// passed on when cooldown doesn't apply.
|
|
|
|
|
func TestCooldownRewriteFailureFailsClosed(t *testing.T) {
|
|
|
|
|
const broken = `{"name": "pkg", "versions": [ 2.0.0`
|
|
|
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
|
|
|
w.Header().Set(headerContentType, contentTypeJSON)
|
|
|
|
|
_, _ = io.WriteString(w, broken)
|
|
|
|
|
}))
|
|
|
|
|
defer upstream.Close()
|
|
|
|
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
purl string
|
|
|
|
|
handler func(*Proxy) http.Handler
|
|
|
|
|
path string
|
|
|
|
|
}{
|
|
|
|
|
{"npm", "pkg:npm/pkg", func(p *Proxy) http.Handler { return NewNPMHandler(p, "http://proxy.test", upstream.URL).Routes() }, "/pkg"},
|
|
|
|
|
{"composer", "pkg:composer/vendor/pkg", func(p *Proxy) http.Handler {
|
|
|
|
|
return NewComposerHandlerWithUpstreams(p, "http://proxy.test", upstream.URL, upstream.URL).Routes()
|
|
|
|
|
}, "/p2/vendor/pkg.json"},
|
|
|
|
|
{"pub", "pkg:pub/pkg", func(p *Proxy) http.Handler {
|
|
|
|
|
return NewPubHandlerWithUpstream(p, "http://proxy.test", upstream.URL).Routes()
|
|
|
|
|
}, "/api/packages/pkg"},
|
|
|
|
|
{"pypi json", "pkg:pypi/pkg", func(p *Proxy) http.Handler {
|
|
|
|
|
return NewPyPIHandlerWithUpstreams(p, "http://proxy.test", upstream.URL, "").Routes()
|
|
|
|
|
}, "/pypi/pkg/json"},
|
|
|
|
|
}
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
|
|
|
proxy.Cooldown = &cooldown.Config{Default: "7d"}
|
|
|
|
|
wantStatus(t, serveGet(t, tt.handler(proxy), tt.path), http.StatusBadGateway, "2.0.0")
|
|
|
|
|
})
|
|
|
|
|
t.Run(tt.name+" exempt", func(t *testing.T) {
|
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
|
|
|
proxy.Cooldown = &cooldown.Config{Default: "7d", Packages: map[string]string{tt.purl: "0"}}
|
|
|
|
|
w := serveGet(t, tt.handler(proxy), tt.path)
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Errorf("status = %d, want 200 for a package cooldown doesn't apply to", w.Code)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|