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_attestations_test.go
Simon Chrzanowski 50e65a31d8
Serve npm attestations through the proxy
Packuments list each version's provenance at dist.attestations.url on
registry.npmjs.org. npm takes only the path of that URL and resolves it
against the configured registry, so behind the proxy `npm audit
signatures` asked for /-/npm/v1/attestations/... at the proxy's root,
outside /npm, and could not verify attestations.

The rewrite now points dist.attestations.url at
/npm/-/npm/v1/attestations/{name}@{version}, and the handler serves that
path. It reads the version's attestations URL from the packument and
fetches it from the package's registry, the route's for routed packages;
a URL on another host is not followed and the registry's standard path
is asked instead. Attestations never change once published, so they are
cached as immutable metadata.

Versions blocked by the denylist get 403 and versions held back by
cooldown 404, as their tarballs do. Pushed versions have no upstream
attestations and get 404.
2026-10-30 08:26:27 +00:00

191 lines
7.7 KiB
Go

package handler
import (
"net/http"
"slices"
"strings"
"testing"
"time"
"github.com/git-pkgs/cooldown"
)
const testAttestations = `{"attestations":[{"predicateType":"https://slsa.dev/provenance/v1","bundle":{}}]}`
// attestedPackument returns a packument whose version lists attestations at
// attestationsURL, resolved against the upstream's URL when it starts with
// "/", and was published age ago.
func attestedPackument(name, version, attestationsURL string, age time.Duration) func(string) string {
published := time.Now().Add(-age).UTC().Format(time.RFC3339)
return func(baseURL string) string {
if strings.HasPrefix(attestationsURL, "/") {
attestationsURL = baseURL + attestationsURL
}
dist := `{"tarball":"` + baseURL + "/" + name + `/-/x-` + version + `.tgz"`
if attestationsURL != "" {
dist += `,"attestations":{"url":"` + attestationsURL + `","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}}`
}
dist += "}"
return `{"name":"` + name + `","dist-tags":{"latest":"` + version + `"},` +
`"versions":{"` + version + `":{"version":"` + version + `","dist":` + dist + `}},` +
`"time":{"` + version + `":"` + published + `"}}`
}
}
func attestationsFile(string) string { return testAttestations }
func newAttestationsProxy(t *testing.T) *Proxy {
t.Helper()
proxy, _, _, _ := setupTestProxy(t)
proxy.HTTPClient = http.DefaultClient
proxy.CacheMetadata = true
proxy.MetadataTTL = time.Hour
return proxy
}
func TestNPMAttestationsServedAndCached(t *testing.T) {
upstream := newNPMUpstream(t, map[string]func(string) string{
"/sigstore": attestedPackument("sigstore", "3.0.0", "/-/npm/v1/attestations/sigstore@3.0.0", 30*24*time.Hour),
"/-/npm/v1/attestations/sigstore@3.0.0": attestationsFile,
}, http.StatusOK)
h := NewNPMHandler(newAttestationsProxy(t), "http://proxy.test", upstream.URL)
for range 2 {
w := serveNPM(t, h, http.MethodGet, "/-/npm/v1/attestations/sigstore@3.0.0", nil, nil)
if w.Code != http.StatusOK || w.Body.String() != testAttestations {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
}
fetched := 0
for _, p := range upstream.requested() {
if p == "/-/npm/v1/attestations/sigstore@3.0.0" {
fetched++
}
}
if fetched != 1 {
t.Errorf("attestations fetched %d times, want once: %v", fetched, upstream.requested())
}
}
func TestNPMAttestationsRoutedPackageStaysOnRoute(t *testing.T) {
public := newNPMUpstream(t, map[string]func(string) string{}, http.StatusOK)
private := newNPMUpstream(t, map[string]func(string) string{
routedNPMPath: attestedPackument("@example/widgets", "1.0.0", "/registry/-/npm/v1/attestations/@example%2fwidgets@1.0.0", 0),
"/registry/-/npm/v1/attestations/@example/widgets@1.0.0": attestationsFile,
}, http.StatusOK)
h := newRoutedNPMHandler(newAttestationsProxy(t), public, private)
for _, target := range []string{
"/-/npm/v1/attestations/@example%2fwidgets@1.0.0",
"/-/npm/v1/attestations/@example/widgets@1.0.0",
} {
w := serveNPM(t, h, http.MethodGet, target, nil, nil)
if w.Code != http.StatusOK || w.Body.String() != testAttestations {
t.Errorf("%s: status = %d, body = %s", target, w.Code, w.Body.String())
}
}
if got := public.requested(); len(got) != 0 {
t.Errorf("default registry was queried for a routed package: %v", got)
}
}
// TestNPMAttestationsOnlyAskThePackageRegistry checks that an attestations
// URL on another host is not followed: the proxy asks the package's registry
// at its standard path instead.
func TestNPMAttestationsOnlyAskThePackageRegistry(t *testing.T) {
other := newNPMUpstream(t, map[string]func(string) string{"/-/npm/v1/attestations/demo@1.0.0": attestationsFile}, http.StatusOK)
upstream := newNPMUpstream(t, map[string]func(string) string{
"/demo": attestedPackument("demo", "1.0.0", other.URL+"/-/npm/v1/attestations/demo@1.0.0", 0),
"/-/npm/v1/attestations/demo@1.0.0": attestationsFile,
}, http.StatusOK)
h := NewNPMHandler(newAttestationsProxy(t), "http://proxy.test", upstream.URL)
w := serveNPM(t, h, http.MethodGet, "/-/npm/v1/attestations/demo@1.0.0", nil, nil)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
if got := other.requested(); len(got) != 0 {
t.Errorf("host named in the packument was queried: %v", got)
}
if !slices.Contains(upstream.requested(), "/-/npm/v1/attestations/demo@1.0.0") {
t.Errorf("registry not asked for attestations: %v", upstream.requested())
}
}
func TestNPMAttestationsWithheld(t *testing.T) {
tests := []struct {
name string
packument func(string) string
setup func(t *testing.T, p *Proxy)
wantStatus int
}{
{"version has none", attestedPackument("demo", "1.0.0", "", 0), nil, http.StatusNotFound},
{"version in cooldown", attestedPackument("demo", "1.0.0", "/-/npm/v1/attestations/demo@1.0.0", time.Hour),
func(_ *testing.T, p *Proxy) { p.Cooldown = &cooldown.Config{Default: "7d"} }, http.StatusNotFound},
{"version denied", attestedPackument("demo", "1.0.0", "/-/npm/v1/attestations/demo@1.0.0", 0),
func(t *testing.T, p *Proxy) { setTestDenylist(t, p, "pkg:npm/demo@1.0.0") }, http.StatusForbidden},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
upstream := newNPMUpstream(t, map[string]func(string) string{
"/demo": tt.packument,
"/-/npm/v1/attestations/demo@1.0.0": attestationsFile,
}, http.StatusOK)
proxy := newAttestationsProxy(t)
if tt.setup != nil {
tt.setup(t, proxy)
}
h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL)
w := serveNPM(t, h, http.MethodGet, "/-/npm/v1/attestations/demo@1.0.0", nil, nil)
if w.Code != tt.wantStatus {
t.Errorf("status = %d, want %d; body: %s", w.Code, tt.wantStatus, w.Body.String())
}
if slices.Contains(upstream.requested(), "/-/npm/v1/attestations/demo@1.0.0") {
t.Error("attestations were fetched for a withheld version")
}
})
}
}
func TestNPMAttestationsRejectsInvalidPaths(t *testing.T) {
upstream := newNPMUpstream(t, map[string]func(string) string{}, http.StatusOK)
h := NewNPMHandler(newAttestationsProxy(t), "http://proxy.test", upstream.URL)
for _, target := range []string{
"/-/npm/v1/attestations/demo",
"/-/npm/v1/attestations/demo@",
"/-/npm/v1/attestations/@demo@1.0.0",
"/-/npm/v1/attestations/@scope/a/b@1.0.0",
"/-/npm/v1/attestations/demo@1.0.0/x",
"/-/npm/v1/attestations/..%2f..%2fetc@1.0.0",
} {
w := serveNPM(t, h, http.MethodGet, target, nil, nil)
if w.Code != http.StatusBadRequest {
t.Errorf("%s: status = %d, want 400", target, w.Code)
}
}
if got := upstream.requested(); len(got) != 0 {
t.Errorf("upstream queried for invalid paths: %v", got)
}
}
func TestNPMMetadataRewritesAttestationsURL(t *testing.T) {
const attestations = "/registry/-/npm/v1/attestations/@example%2fwidgets@1.0.0"
upstream := newNPMUpstream(t, map[string]func(string) string{
routedNPMPath: attestedPackument("@example/widgets", "1.0.0", attestations, 0),
routedNPMPath + "/1.0.0": func(baseURL string) string {
return `{"name":"@example/widgets","version":"1.0.0","dist":{"attestations":{"url":"` + baseURL + attestations +
`"},"tarball":"` + baseURL + `/registry/@example/widgets/-/widgets-1.0.0.tgz"}}`
},
}, http.StatusOK)
h := newRoutedNPMHandler(newAttestationsProxy(t), upstream, upstream)
for _, target := range []string{"/@example%2fwidgets", "/@example%2fwidgets/1.0.0"} {
w := serveNPM(t, h, http.MethodGet, target, nil, nil)
want := `"url":"http://proxy.test/npm/-/npm/v1/attestations/@example%2Fwidgets@1.0.0"`
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), want) {
t.Errorf("%s: status %d, attestations URL not rewritten: %s", target, w.Code, w.Body.String())
}
}
}