Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 15:49:16 -05:00
pkg-proxy/internal/handler/npm_routes_test.go
Simon Chrzanowski b6d7575b88 Don't fetch npm tarballs whose packument can't be read
An uncached tarball is checked against the integrity its packument
declares. When the packument couldn't be fetched or was refused, for
example because it is larger than metadata_max_size or the registry
answered 503, the download went ahead with no integrity at all, and
the unverified tarball was cached and served to every later client.

Without the packument the download now fails like the Composer one
does: 502, or 404 when the registry says the package doesn't exist.
Nothing is fetched or cached. Tarballs already in the cache are still
served, since they were verified when they were stored, and a packument
that lists the version without a usable checksum still falls back to
an unverified fetch as before.
2026-10-24 08:06:10 +02:00

301 lines
12 KiB
Go

package handler
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/git-pkgs/cooldown"
"github.com/git-pkgs/registries/fetch"
)
// npmUpstreamRecorder serves canned packuments and records which paths were
// requested.
type npmUpstreamRecorder struct {
*httptest.Server
mu sync.Mutex
requests []string
}
func newNPMUpstream(t *testing.T, packuments map[string]func(baseURL string) string, status int) *npmUpstreamRecorder {
t.Helper()
u := &npmUpstreamRecorder{}
u.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u.mu.Lock()
u.requests = append(u.requests, r.URL.Path)
u.mu.Unlock()
if status != http.StatusOK {
w.WriteHeader(status)
return
}
packument, ok := packuments[r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, packument(u.URL))
}))
t.Cleanup(u.Close)
return u
}
func (u *npmUpstreamRecorder) requested() []string {
u.mu.Lock()
defer u.mu.Unlock()
return append([]string(nil), u.requests...)
}
func routedTestPackument(version, tarballBase string) func(string) string {
return func(baseURL string) string {
return `{"name":"@example/widgets","dist-tags":{"latest":"` + version + `"},"versions":{"` + version +
`":{"version":"` + version + `","dist":{"tarball":"` + baseURL + tarballBase + `/@example/widgets/-/widgets-` + version + `.tgz"}}}}`
}
}
const routedNPMPath = "/registry/@example/widgets"
func newRoutedNPMHandler(proxy *Proxy, public, private *npmUpstreamRecorder) *NPMHandler {
return NewNPMHandler(proxy, "http://proxy.test", public.URL).
WithPackageRoutes(map[string]string{"@example/*": private.URL + "/registry"})
}
func TestNPMRoutedMetadataComesFromRouteOnly(t *testing.T) {
public := newNPMUpstream(t, map[string]func(string) string{"/@example/widgets": routedTestPackument("9.9.9", "")}, http.StatusOK)
private := newNPMUpstream(t, map[string]func(string) string{routedNPMPath: routedTestPackument("1.0.0", "/registry")}, http.StatusOK)
proxy, _, _, _ := setupTestProxy(t)
proxy.HTTPClient = http.DefaultClient
h := newRoutedNPMHandler(proxy, public, private)
w := serveNPM(t, h, http.MethodGet, "/@example%2fwidgets", nil, nil)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
}
if got := public.requested(); len(got) != 0 {
t.Errorf("default registry was queried for a routed package: %v", got)
}
if strings.Contains(w.Body.String(), "9.9.9") {
t.Errorf("routed package served the default registry's metadata: %s", w.Body.String())
}
wantTarball := "http://proxy.test/npm/@example%2Fwidgets/-/widgets-1.0.0.tgz"
if !strings.Contains(w.Body.String(), wantTarball) {
t.Errorf("tarball not rewritten to %q: %s", wantTarball, w.Body.String())
}
}
func TestNPMRoutedMetadataDoesNotFallBack(t *testing.T) {
tests := []struct {
name string
privateStatus int
wantStatus int
}{
{"package missing on route", http.StatusOK, http.StatusNotFound},
{"route rejects credentials", http.StatusUnauthorized, http.StatusBadGateway},
{"route unavailable", http.StatusInternalServerError, http.StatusBadGateway},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
public := newNPMUpstream(t, map[string]func(string) string{"/@example/widgets": routedTestPackument("9.9.9", "")}, http.StatusOK)
private := newNPMUpstream(t, map[string]func(string) string{}, tt.privateStatus)
proxy, _, _, _ := setupTestProxy(t)
proxy.HTTPClient = http.DefaultClient
h := newRoutedNPMHandler(proxy, public, private)
w := serveNPM(t, h, http.MethodGet, "/@example%2fwidgets", nil, nil)
if w.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", w.Code, tt.wantStatus)
}
if got := public.requested(); len(got) != 0 {
t.Errorf("default registry was queried for a routed package: %v", got)
}
})
}
}
func TestNPMRoutedMetadataIgnoresDefaultRegistryCache(t *testing.T) {
public := newNPMUpstream(t, map[string]func(string) string{"/@example/widgets": routedTestPackument("9.9.9", "")}, http.StatusOK)
private := newNPMUpstream(t, map[string]func(string) string{routedNPMPath: routedTestPackument("1.0.0", "/registry")}, http.StatusOK)
proxy, _, _, _ := setupTestProxy(t)
proxy.HTTPClient = http.DefaultClient
proxy.CacheMetadata = true
proxy.MetadataTTL = time.Hour
if w := serveNPM(t, NewNPMHandler(proxy, "http://proxy.test", public.URL), http.MethodGet, "/@example%2fwidgets", nil, nil); w.Code != http.StatusOK {
t.Fatalf("priming the default registry cache: status %d", w.Code)
}
w := serveNPM(t, newRoutedNPMHandler(proxy, public, private), http.MethodGet, "/@example%2fwidgets", nil, nil)
if strings.Contains(w.Body.String(), "9.9.9") {
t.Errorf("routed package served metadata cached from the default registry: %s", w.Body.String())
}
}
func TestNPMUnroutedMetadataUsesDefaultRegistry(t *testing.T) {
public := newNPMUpstream(t, map[string]func(string) string{"/lodash": routedTestPackument("4.17.21", "")}, http.StatusOK)
private := newNPMUpstream(t, map[string]func(string) string{}, http.StatusOK)
proxy, _, _, _ := setupTestProxy(t)
proxy.HTTPClient = http.DefaultClient
h := newRoutedNPMHandler(proxy, public, private)
w := serveNPM(t, h, http.MethodGet, "/lodash", nil, nil)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
}
if got := private.requested(); len(got) != 0 {
t.Errorf("route was queried for an unrouted package: %v", got)
}
}
func TestNPMRoutedDownloadResolvesFromRoute(t *testing.T) {
public := newNPMUpstream(t, map[string]func(string) string{"/@example/widgets": routedTestPackument("1.0.0", "")}, http.StatusOK)
private := newNPMUpstream(t, map[string]func(string) string{routedNPMPath: routedTestPackument("1.0.0", "/registry")}, http.StatusOK)
proxy, _, _, artifactFetcher := setupTestProxy(t)
proxy.HTTPClient = http.DefaultClient
artifactFetcher.artifact = &fetch.Artifact{
Body: io.NopCloser(strings.NewReader("package")),
ContentType: "application/gzip",
}
h := newRoutedNPMHandler(proxy, public, private)
w := serveNPM(t, h, http.MethodGet, "/@example/widgets/-/widgets-1.0.0.tgz", nil, nil)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
}
if want := private.URL + "/registry/@example/widgets/-/widgets-1.0.0.tgz"; artifactFetcher.fetchedURL != want {
t.Errorf("fetched URL = %q, want %q", artifactFetcher.fetchedURL, want)
}
if got := public.requested(); len(got) != 0 {
t.Errorf("default registry was queried for a routed download: %v", got)
}
}
func TestNPMRoutedDownloadWithoutMetadataStaysOnRoute(t *testing.T) {
public := newNPMUpstream(t, map[string]func(string) string{}, http.StatusOK)
private := newNPMUpstream(t, map[string]func(string) string{}, http.StatusInternalServerError)
proxy, _, _, artifactFetcher := setupTestProxy(t)
proxy.HTTPClient = http.DefaultClient
artifactFetcher.artifact = &fetch.Artifact{
Body: io.NopCloser(strings.NewReader("package")),
ContentType: "application/gzip",
}
h := newRoutedNPMHandler(proxy, public, private)
w := serveNPM(t, h, http.MethodGet, "/@example/widgets/-/widgets-1.0.0.tgz", nil, nil)
// Without the route's metadata the tarball can't be verified, so it is
// not fetched at all, and in particular not from the default registry.
if w.Code != http.StatusBadGateway {
t.Errorf("status = %d, want 502", w.Code)
}
if artifactFetcher.fetchedURL != "" {
t.Errorf("fetched URL = %q, want none", artifactFetcher.fetchedURL)
}
}
func TestNPMRoutedDownloadIgnoresDefaultRegistryArtifact(t *testing.T) {
public := newNPMUpstream(t, map[string]func(string) string{"/@example/widgets": routedTestPackument("1.0.0", "")}, http.StatusOK)
private := newNPMUpstream(t, map[string]func(string) string{routedNPMPath: routedTestPackument("1.0.0", "/registry")}, http.StatusOK)
proxy, _, _, artifactFetcher := setupTestProxy(t)
proxy.HTTPClient = http.DefaultClient
artifactFetcher.artifact = &fetch.Artifact{
Body: io.NopCloser(strings.NewReader("public package")),
ContentType: "application/gzip",
}
if w := serveNPM(t, NewNPMHandler(proxy, "http://proxy.test", public.URL), http.MethodGet, "/@example/widgets/-/widgets-1.0.0.tgz", nil, nil); w.Code != http.StatusOK {
t.Fatalf("priming the artifact cache: status %d; body: %s", w.Code, w.Body.String())
}
artifactFetcher.artifact = &fetch.Artifact{
Body: io.NopCloser(strings.NewReader("private package")),
ContentType: "application/gzip",
}
w := serveNPM(t, newRoutedNPMHandler(proxy, public, private), http.MethodGet, "/@example/widgets/-/widgets-1.0.0.tgz", nil, nil)
if w.Body.String() != "private package" {
t.Errorf("routed download served %q, want the routed registry's package", w.Body.String())
}
}
// timedTestPackument is routedTestPackument with a publish time for the
// version, published age ago.
func timedTestPackument(version, tarballBase string, age time.Duration) func(string) string {
published := time.Now().Add(-age).UTC().Format(time.RFC3339)
return func(baseURL string) string {
return `{"name":"@example/widgets","dist-tags":{"latest":"` + version + `"},"versions":{"` + version +
`":{"version":"` + version + `","dist":{"tarball":"` + baseURL + tarballBase + `/@example/widgets/-/widgets-` + version + `.tgz"}}},` +
`"time":{"` + version + `":"` + published + `"}}`
}
}
// TestNPMRoutedCooldownUsesRoutePublishTimes is a regression test: the
// download cooldown check stored publish times under the package's plain
// version PURL, so a time read from one registry was trusted for the same
// version from another. A route's version published an hour ago must stay in
// a seven-day cooldown even when the default registry's version of the same
// name is a month old, whichever was downloaded first, and changing a route's
// upstream must not carry over the old one's times.
func TestNPMRoutedCooldownUsesRoutePublishTimes(t *testing.T) {
const tarball = "/@example/widgets/-/widgets-1.0.0.tgz"
month, hour := 30*24*time.Hour, time.Hour
type step struct {
handler string // "public", "route" or "other route"
wantStatus int
}
tests := []struct {
name string
steps []step
}{
{"route added after a public download", []step{
{"public", http.StatusOK},
{"route", http.StatusNotFound},
}},
{"public download after a routed one", []step{
{"route", http.StatusNotFound},
{"public", http.StatusOK},
}},
{"route moved to another upstream", []step{
{"other route", http.StatusOK},
{"route", http.StatusNotFound},
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
public := newNPMUpstream(t, map[string]func(string) string{"/@example/widgets": timedTestPackument("1.0.0", "", month)}, http.StatusOK)
private := newNPMUpstream(t, map[string]func(string) string{routedNPMPath: timedTestPackument("1.0.0", "/registry", hour)}, http.StatusOK)
old := newNPMUpstream(t, map[string]func(string) string{routedNPMPath: timedTestPackument("1.0.0", "/registry", month)}, http.StatusOK)
proxy, _, _, artifactFetcher := setupTestProxy(t)
proxy.HTTPClient = http.DefaultClient
proxy.CacheMetadata = true
proxy.MetadataTTL = time.Hour
proxy.Cooldown = &cooldown.Config{Default: "7d"}
handlers := map[string]*NPMHandler{
"public": NewNPMHandler(proxy, "http://proxy.test", public.URL),
"route": newRoutedNPMHandler(proxy, public, private),
"other route": newRoutedNPMHandler(proxy, public, old),
}
for _, s := range tt.steps {
artifactFetcher.artifact = &fetch.Artifact{
Body: io.NopCloser(strings.NewReader("package")),
ContentType: "application/gzip",
}
w := serveNPM(t, handlers[s.handler], http.MethodGet, tarball, nil, nil)
if w.Code != s.wantStatus {
t.Errorf("%s download: status %d, want %d; body: %s", s.handler, w.Code, s.wantStatus, w.Body.String())
}
}
})
}
}