Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 23:59:16 -05:00
pkg-proxy/internal/handler/npm_attestations.go
Simon Chrzanowski d073fdcd52 Close npm, PyPI and pub cooldown gaps in metadata and downloads
npm: a tarball request for a version the cached packument did not list
yet (cached within metadata_ttl before the release) found no publish
time and was let through cooldown, and fetched without integrity. When
cooldown applies and the cached packument lacks the version, fetch it
again and decide on what upstream lists now; if it can't be fetched,
fail as when a publish time can't be read.

npm: cooldown only moved dist-tags.latest. Other tags kept naming
versions in cooldown, while the denylist removes such tags. Cooldown
now removes them too, and drops latest when no version is left, in
both the in-memory and streamed rewrites.

PyPI: the simple JSON rewrite removed filtered files but left their
versions in the PEP 700 versions list. Remove them there too.

pub: latest was never rewritten, so its archive_url pointed at pub.dev,
it kept naming a version in cooldown when no version was left, and the
replacement was the last list entry, which may be a prerelease. Point
latest's archive_url at the proxy, replace a held back latest with the
newest stable version left (or the newest prerelease), and drop it
when nothing is left.
2026-11-08 08:00:14 +01:00

110 lines
3.8 KiB
Go

package handler
import (
"errors"
"net/http"
"net/url"
"strings"
"sync"
"github.com/git-pkgs/proxy/internal/jsonscan"
)
// errNoAttestations answers a request for a version whose packument entry
// lists no attestations.
var errNoAttestations = errors.New("attestations not found")
// handleAttestations serves /-/npm/v1/attestations/{name}@{version}, which
// `npm audit signatures` reads to verify provenance. Packuments point
// dist.attestations.url here (see npm.AttestationsURL).
func (h *NPMHandler) handleAttestations(w http.ResponseWriter, r *http.Request) {
packageName, version, ok := parseNPMAttestationsPath(strings.TrimPrefix(r.URL.Path, npmAttestationsPrefix))
if !ok {
JSONError(w, http.StatusBadRequest, "invalid package name")
return
}
if h.proxy.versionDenied("npm", packageName, version) {
JSONError(w, http.StatusForbidden, ErrVersionDenied.Error()+": "+canonicalVersionPURL("npm", packageName, version))
return
}
local, err := h.npmLocalEntries(packageName)
if err != nil {
h.proxy.Logger.Error("failed to read pushed npm versions", "package", packageName, "error", err)
JSONError(w, http.StatusInternalServerError, "failed to read pushed versions")
return
}
if _, pushed := findNPMLocalEntry(local, version); pushed || h.proxy.hostedOnly("npm", packageName) {
JSONError(w, http.StatusNotFound, errNoAttestations.Error())
return
}
registryURL, cacheKey := h.sourceFor(packageName)
metadata := sync.OnceValues(func() (npmVersionData, error) {
return h.versionData(r.Context(), packageName, registryURL+"/"+url.PathEscape(packageName), cacheKey, version)
})
inCooldown, err := h.versionInCooldown(packageName, version, metadata)
if err != nil {
if writeUpstreamHeld(w, err) {
return
}
h.proxy.Logger.Warn("cooldown: could not read npm publish time",
"package", packageName, "version", version, "error", err)
JSONError(w, http.StatusBadGateway, "failed to check cooldown")
return
}
if inCooldown {
JSONError(w, http.StatusNotFound, ErrVersionNotFound.Error())
return
}
data, err := metadata()
if err != nil {
if errors.Is(err, ErrUpstreamNotFound) {
JSONError(w, http.StatusNotFound, ErrPackageNotFound.Error())
return
}
if writeUpstreamHeld(w, err) {
return
}
h.proxy.Logger.Error("failed to fetch npm metadata", "error", err)
JSONError(w, http.StatusBadGateway, "failed to fetch from upstream")
return
}
listed, _, _ := jsonscan.LookupString(data.entry, "dist", "attestations", "url")
if listed == "" {
JSONError(w, http.StatusNotFound, errNoAttestations.Error())
return
}
upstreamURL, err := validateRegistryURL("attestations", listed, registryURL)
if err != nil {
// Mirrors of npmjs can keep its URLs in packuments. The proxy only
// ever asks the package's own registry.
upstreamURL = registryURL + "/-/npm/v1/attestations/" + url.PathEscape(packageName) + "@" + url.PathEscape(version)
}
h.proxy.proxyImmutable(w, r, upstreamURL, "npm", cacheKey+"/-/attestations/"+version, contentTypeJSON)
}
// parseNPMAttestationsPath splits "{name}@{version}" as the attestations
// endpoint takes it, where name may be "@scope/name" with the slash encoded
// or not. It rejects anything that is not one package and one version.
func parseNPMAttestationsPath(spec string) (packageName, version string, ok bool) {
at := strings.LastIndex(spec, "@")
if at <= 0 {
return "", "", false
}
packageName, version = spec[:at], spec[at+1:]
if version == "" || strings.ContainsAny(version, "/\\") || containsPathTraversal(packageName) || containsPathTraversal(version) {
return "", "", false
}
scope, name, scoped := strings.Cut(packageName, "/")
switch {
case scoped:
ok = len(scope) > 1 && scope[0] == '@' && name != "" && !strings.ContainsAny(name, "/@")
default:
ok = !strings.ContainsAny(packageName, "@\\")
}
return packageName, version, ok
}