mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-11-09 15:49:16 -05:00
npm versions published before dist.integrity existed carry only a SHA-1 dist.shasum, and Composer metadata lists only a SHA-1 dist.shasum, so neither could be checked. - Accept native integrity given as an algorithm-prefixed hex checksum, verified with integrity's exact-digest check, so a recorded "sha1-<hex>" is also checked on cache hits - npm: fall back to dist.shasum when dist.integrity is missing or cannot be parsed; a usable dist.integrity still takes precedence - Composer: verify an archive against its dist.shasum, except another commit of a branch fetched in place of the listed one, which the listed shasum does not describe - Fetch unverified as before when no shasum is listed, as Packagist does for GitHub archives, or it is not a SHA-1 digest
169 lines
6.1 KiB
Go
169 lines
6.1 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha1" //nolint:gosec // npm's dist.shasum is SHA-1
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/git-pkgs/registries/fetch"
|
|
)
|
|
|
|
const npmIntegrityTarball = "tarball data"
|
|
|
|
var npmIntegrityCases = []struct {
|
|
name string
|
|
dist map[string]string
|
|
wantOK bool
|
|
}{
|
|
{"matching sha512", map[string]string{"integrity": sha512SRI(npmIntegrityTarball)}, true},
|
|
{"mismatched sha512", map[string]string{"integrity": sha512SRI("other bytes")}, false},
|
|
{"matching sha256", map[string]string{"integrity": sha256SRI(npmIntegrityTarball)}, true},
|
|
{"strongest algorithm decides", map[string]string{"integrity": sha256SRI("other bytes") + " " + sha512SRI(npmIntegrityTarball)}, true},
|
|
{"any digest of the strongest algorithm", map[string]string{"integrity": sha512SRI("other bytes") + " " + sha512SRI(npmIntegrityTarball)}, true},
|
|
{"unparseable integrity", map[string]string{"integrity": "sha512-nope"}, true},
|
|
{"matching shasum", map[string]string{"shasum": sha1Hex(npmIntegrityTarball)}, true},
|
|
{"mismatched shasum", map[string]string{"shasum": sha1Hex("other bytes")}, false},
|
|
{"integrity wins over shasum", map[string]string{"integrity": sha512SRI("other bytes"), "shasum": sha1Hex(npmIntegrityTarball)}, false},
|
|
{"unparseable integrity falls back to shasum", map[string]string{"integrity": "sha512-nope", "shasum": sha1Hex("other bytes")}, false},
|
|
{"unparseable shasum", map[string]string{"shasum": "not-a-sha1"}, true},
|
|
{"no checksum", map[string]string{}, true},
|
|
}
|
|
|
|
// TestNPMDownloadVerifiesDeclaredIntegrity checks a freshly fetched tarball
|
|
// against dist.integrity: a mismatch is a 502 and nothing is cached.
|
|
func TestNPMDownloadVerifiesDeclaredIntegrity(t *testing.T) {
|
|
for _, tt := range npmIntegrityCases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
proxy, srv := npmIntegrityProxy(t, tt.dist)
|
|
|
|
resp, body := npmIntegrityGet(t, srv)
|
|
if wantStatus := map[bool]int{true: http.StatusOK, false: http.StatusBadGateway}[tt.wantOK]; resp.StatusCode != wantStatus {
|
|
t.Fatalf("status = %d, want %d; body: %s", resp.StatusCode, wantStatus, body)
|
|
}
|
|
if tt.wantOK && body != npmIntegrityTarball {
|
|
t.Errorf("body = %q, want the tarball", body)
|
|
}
|
|
cached, err := proxy.GetCachedArtifact(context.Background(), "npm", "leftpad", "1.0.0", "leftpad-1.0.0.tgz")
|
|
if err != nil {
|
|
t.Fatalf("reading cache: %v", err)
|
|
}
|
|
if cached != nil {
|
|
_ = cached.Reader.Close()
|
|
}
|
|
if (cached != nil) != tt.wantOK {
|
|
t.Errorf("cached = %v, want %v", cached != nil, tt.wantOK)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestNPMDownloadRecordsDeclaredIntegrity checks that the integrity a tarball
|
|
// was verified against is kept with the cached version, so cache hits are
|
|
// verified against it too.
|
|
func TestNPMDownloadRecordsDeclaredIntegrity(t *testing.T) {
|
|
sri := sha512SRI(npmIntegrityTarball)
|
|
proxy, srv := npmIntegrityProxy(t, map[string]string{"integrity": sri})
|
|
if resp, body := npmIntegrityGet(t, srv); resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d; body: %s", resp.StatusCode, body)
|
|
}
|
|
|
|
pkgPURL, versionPURL, err := packagePURLStrings("npm", "leftpad", "1.0.0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
record, err := proxy.DB.GetCachedArtifact(pkgPURL, versionPURL, "leftpad-1.0.0.tgz")
|
|
if err != nil || record == nil {
|
|
t.Fatalf("cache record = %v, %v", record, err)
|
|
}
|
|
if record.Integrity.String != sri {
|
|
t.Errorf("recorded integrity = %q, want %q", record.Integrity.String, sri)
|
|
}
|
|
}
|
|
|
|
// TestNPMStreamedDownloadVerifiesDeclaredIntegrity checks dist.integrity
|
|
// when tarballs are streamed without being stored: a mismatch aborts the
|
|
// response, so the client never receives a complete body.
|
|
func TestNPMStreamedDownloadVerifiesDeclaredIntegrity(t *testing.T) {
|
|
for _, tt := range npmIntegrityCases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
proxy, srv := npmIntegrityProxy(t, tt.dist)
|
|
proxy.StreamArtifacts = true
|
|
|
|
// The response is aborted on a mismatch: before its headers when
|
|
// the body fits in the server's buffer, during the body otherwise.
|
|
body, err := func() ([]byte, error) {
|
|
resp, err := http.Get(srv.URL + "/leftpad/-/leftpad-1.0.0.tgz")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
return io.ReadAll(resp.Body)
|
|
}()
|
|
|
|
if tt.wantOK {
|
|
if err != nil || string(body) != npmIntegrityTarball {
|
|
t.Errorf("body = %q, %v; want the complete tarball", body, err)
|
|
}
|
|
return
|
|
}
|
|
if err == nil {
|
|
t.Errorf("received the whole body (%q), want the response aborted", body)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func npmIntegrityProxy(t *testing.T, dist map[string]string) (*Proxy, *httptest.Server) {
|
|
t.Helper()
|
|
upstream := npmIntegrityUpstream(t, dist)
|
|
proxy, _, _, fetcher := setupTestProxy(t)
|
|
proxy.HTTPClient = upstream.Client()
|
|
fetcher.artifact = &fetch.Artifact{Body: io.NopCloser(strings.NewReader(npmIntegrityTarball)), Size: int64(len(npmIntegrityTarball))}
|
|
|
|
srv := httptest.NewServer(NewNPMHandler(proxy, "http://proxy.test", upstream.URL).Routes())
|
|
t.Cleanup(srv.Close)
|
|
return proxy, srv
|
|
}
|
|
|
|
func npmIntegrityGet(t *testing.T, srv *httptest.Server) (*http.Response, string) {
|
|
t.Helper()
|
|
resp, err := http.Get(srv.URL + "/leftpad/-/leftpad-1.0.0.tgz")
|
|
if err != nil {
|
|
t.Fatalf("request failed: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return resp, string(body)
|
|
}
|
|
|
|
// npmIntegrityUpstream serves a leftpad packument whose 1.0.0 carries dist.
|
|
func npmIntegrityUpstream(t *testing.T, dist map[string]string) *httptest.Server {
|
|
t.Helper()
|
|
packument, err := json.Marshal(map[string]any{
|
|
"name": "leftpad",
|
|
"dist-tags": map[string]string{"latest": "1.0.0"},
|
|
"versions": map[string]any{
|
|
"1.0.0": map[string]any{"dist": dist},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("encoding packument: %v", err)
|
|
}
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", contentTypeJSON)
|
|
_, _ = w.Write(packument)
|
|
}))
|
|
t.Cleanup(upstream.Close)
|
|
return upstream
|
|
}
|
|
|
|
func sha1Hex(data string) string {
|
|
sum := sha1.Sum([]byte(data)) //nolint:gosec // npm's dist.shasum is SHA-1
|
|
return hex.EncodeToString(sum[:])
|
|
}
|