Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions internal/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -1030,6 +1030,14 @@ var ErrArtifactBlocked = errors.New("artifact blocked by security scan")
// ErrVersionDenied indicates an operator explicitly denied this package version.
var ErrVersionDenied = errors.New("package version blocked by denylist")

// ErrVersionNotFound indicates the requested version is not available through
// the proxy, including when cooldown or other filtering removed it.
var ErrVersionNotFound = errors.New("version not found")

// ErrPackageNotFound indicates the requested package metadata is not available
// through the proxy or upstream registry.
var ErrPackageNotFound = errors.New("package not found")

// serveArtifactError writes response for a failed fetch:
// 404 when upstream reports artifact missing, 403 when a security scan or
// denylist blocked the artifact, 502 otherwise.
Expand Down
188 changes: 179 additions & 9 deletions internal/handler/npm.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,16 +175,25 @@ func (h *NPMHandler) sourceFor(packageName string) (registryURL, cacheKey string

// handlePackageMetadata proxies package metadata from upstream and rewrites tarball URLs.
func (h *NPMHandler) handlePackageMetadata(w http.ResponseWriter, r *http.Request) {
packageName := h.extractPackageName(r)
packagePath := h.extractPackageName(r)
if packagePath == "" || containsPathTraversal(packagePath) {
JSONError(w, http.StatusBadRequest, "invalid package name")
return
}
packageName, registryPath := npmMetadataPath(packagePath)
if packageName == "" {
JSONError(w, http.StatusBadRequest, "invalid package name")
return
}

h.proxy.Logger.Info("npm metadata request", "package", packageName)
h.proxy.Logger.Info("npm metadata request", "package", packageName, "path", packagePath)

registryURL, cacheKey := h.sourceFor(packageName)
upstreamURL := fmt.Sprintf("%s/%s", registryURL, url.PathEscape(packageName))
// A version or dist-tag is its own path segment. Escaping the whole path
// turns "lodash/4.17.21" into "lodash%2F4.17.21", which the registry
// rejects with 405.
upstreamURL := registryURL + "/" + registryPath
metadataCacheKey := npmMetadataCacheKey(cacheKey, packagePath, packageName)

// Prefer the smaller abbreviated packument format but include application/json
// as a fallback so upstreams that reject the abbreviated type (e.g. JFrog
Expand All @@ -198,31 +207,49 @@ func (h *NPMHandler) handlePackageMetadata(w http.ResponseWriter, r *http.Reques
accept = contentTypeJSON
}

if rewritten, ok := h.proxy.storedRewrite("npm", cacheKey, h.proxyURL, cacheKey); ok {
if rewritten, ok := h.proxy.storedRewrite("npm", metadataCacheKey, h.proxyURL, metadataCacheKey); ok {
w.Header().Set(headerContentType, contentTypeJSON)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(rewritten)
return
}

body, _, err := h.proxy.FetchOrCacheMetadata(r.Context(), "npm", cacheKey, upstreamURL, accept)
specifier, versionLookup := npmVersionSpecifier(packagePath, packageName)
if versionLookup && h.npmVersionMetadataUsesFilteredPackument(packageName) {
h.handleFilteredNPMVersionMetadata(w, r, packageName, specifier, accept, registryURL, cacheKey, metadataCacheKey)
return
}

body, _, err := h.proxy.FetchOrCacheMetadata(r.Context(), "npm", metadataCacheKey, upstreamURL, accept)
if err != nil {
if errors.Is(err, ErrUpstreamNotFound) {
JSONError(w, http.StatusNotFound, "package not found")
if versionLookup {
JSONError(w, http.StatusNotFound, ErrVersionNotFound.Error())
} else {
JSONError(w, http.StatusNotFound, ErrPackageNotFound.Error())
}
return
}
h.proxy.Logger.Error("failed to fetch npm metadata", "error", err)
JSONError(w, http.StatusBadGateway, "failed to fetch from upstream")
return
}

rewritten, err := h.proxy.cachedRewrite(r.Context(), "npm", h.proxyURL, cacheKey, body, func(b []byte) ([]byte, error) {
rewritten, err := h.proxy.cachedRewrite(r.Context(), "npm", h.proxyURL, metadataCacheKey, body, func(b []byte) ([]byte, error) {
return h.rewriteMetadata(packageName, b)
})
if err != nil {
if r.Context().Err() != nil {
return // the client left while waiting on a shared rewrite
}
if errors.Is(err, ErrVersionDenied) {
JSONError(w, http.StatusForbidden, err.Error())
return
}
if errors.Is(err, ErrVersionNotFound) {
JSONError(w, http.StatusNotFound, ErrVersionNotFound.Error())
return
}
if len(h.proxy.Denylist.Versions(canonicalPackagePURL("npm", packageName))) != 0 {
JSONError(w, http.StatusBadGateway, "failed to filter package metadata")
return
Expand All @@ -240,6 +267,112 @@ func (h *NPMHandler) handlePackageMetadata(w http.ResponseWriter, r *http.Reques
_, _ = w.Write(rewritten)
}

// handleFilteredNPMVersionMetadata serves /pkg/<version> and /pkg/<tag> from the
// filtered packument so cooldown and dist-tags match the root metadata response.
func (h *NPMHandler) handleFilteredNPMVersionMetadata(w http.ResponseWriter, r *http.Request, packageName, specifier, accept, registryURL, cacheKey, metadataCacheKey string) {
_, packumentPath := npmMetadataPath(packageName)
packument, _, err := h.proxy.FetchOrCacheMetadata(r.Context(), "npm", cacheKey, registryURL+"/"+packumentPath, accept)
if err != nil {
if errors.Is(err, ErrUpstreamNotFound) {
JSONError(w, http.StatusNotFound, ErrPackageNotFound.Error())
return
}
h.proxy.Logger.Error("failed to fetch npm metadata", "error", err)
JSONError(w, http.StatusBadGateway, "failed to fetch from upstream")
return
}

rewritten, err := h.proxy.cachedRewrite(r.Context(), "npm", h.proxyURL, metadataCacheKey, packument, func(b []byte) ([]byte, error) {
return h.rewriteNPMVersionFromPackument(packageName, specifier, b)
})
if err != nil {
if r.Context().Err() != nil {
return
}
if errors.Is(err, ErrVersionDenied) {
JSONError(w, http.StatusForbidden, err.Error())
return
}
if errors.Is(err, ErrVersionNotFound) {
JSONError(w, http.StatusNotFound, ErrVersionNotFound.Error())
return
}
if len(h.proxy.Denylist.Versions(canonicalPackagePURL("npm", packageName))) != 0 {
JSONError(w, http.StatusBadGateway, "failed to filter package metadata")
return
}
h.proxy.Logger.Warn("failed to rewrite npm version metadata", "error", err)
JSONError(w, http.StatusBadGateway, "failed to filter package metadata")
return
}

w.Header().Set(headerContentType, contentTypeJSON)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(rewritten)
}

// npmMetadataCacheKey qualifies routed-package metadata cache entries with the
// requested version or dist-tag when the client asks for a single version.
func npmMetadataCacheKey(cacheKey, packagePath, packageName string) string {
specifier, versionLookup := npmVersionSpecifier(packagePath, packageName)
if !versionLookup {
return cacheKey
}
return cacheKey + "/" + specifier
}

// npmVersionSpecifier returns the version or dist-tag segment after the package
// name in a metadata path, for example "4.17.21" or "latest".
func npmVersionSpecifier(packagePath, packageName string) (string, bool) {
if packagePath == packageName {
return "", false
}
specifier, ok := strings.CutPrefix(packagePath, packageName+"/")
return specifier, ok
}

func (h *NPMHandler) npmVersionMetadataUsesFilteredPackument(packageName string) bool {
if h.proxy.Cooldown != nil && h.proxy.Cooldown.Enabled() {
return true
}
if h.proxy.Denylist != nil {
return len(h.proxy.Denylist.Versions(canonicalPackagePURL("npm", packageName))) != 0
}
return false
}

// rewriteNPMVersionFromPackument picks one version entry out of an already
// filtered packument and rewrites its tarball URL.
func (h *NPMHandler) rewriteNPMVersionFromPackument(packageName, specifier string, packument []byte) ([]byte, error) {
filtered, err := h.rewriteMetadata(packageName, packument)
if err != nil {
return nil, err
}

target := specifier
if resolved, ok, err := lookupJSONString(filtered, "dist-tags", specifier); err != nil {
return nil, err
} else if ok {
target = resolved
}

if h.proxy.versionDenied("npm", packageName, target) {
return nil, ErrVersionDenied
}

versionRaw, err := lookupJSON(filtered, "versions", target)
if err != nil {
return nil, err
}
if len(versionRaw) == 0 {
return nil, ErrVersionNotFound
}

var out bytes.Buffer
h.writeNPMVersion(&out, packageName, target, versionRaw)
return out.Bytes(), nil
}

// rewriteMetadata rewrites tarball URLs in npm package metadata to point at this proxy.
// If cooldown is enabled, versions published too recently are filtered out.
//
Expand All @@ -258,6 +391,18 @@ func (h *NPMHandler) rewriteMetadata(packageName string, body []byte) ([]byte, e

versionsRaw := doc.value(doc.versionsAt)
if len(versionsRaw) == 0 || versionsRaw[0] != '{' {
version, ok, err := lookupJSONString(body, "version")
if err != nil {
return nil, err
}
if ok {
if h.proxy.versionDenied("npm", packageName, version) {
return nil, ErrVersionDenied
}
var out bytes.Buffer
h.writeNPMVersion(&out, packageName, version, body)
return out.Bytes(), nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This single-version response returns before cooldown filtering. I reproduced it through Routes() with a seven-day cooldown and a version published one hour ago: /demo hides 2.0.0 and sets latest to 1.0.0, but /demo/latest and /demo/2.0.0 both return HTTP 200 with 2.0.0. Following its rewritten tarball URL then returns 404 because the download path enforces cooldown. Apply cooldown to explicit version lookups and resolve tags consistently with the filtered package metadata, with handler-level regression tests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, my bad. Tested it with default cooldown set to 7d and looked up @babel/core 8.0.7 (published five days ago). 8.0.6 works just fine, so does latest (returns 8.0.6)

$ curl -sS 'http://localhost:8080/npm/https://github.com/babel%2Fcore/8.0.7' | jq
{
  "error": "package not found"
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, "package not found" is rather misleading here - the package is found, just the version falls under the cooldown policy. Let me reword that.

Also, npm itself has a different copy for non-existent versions (the cooldown-masked version is similar to a non-existent one)

$ curl -s https://registry.npmjs.org/@babel%2Fcore/8.0.8
"version not found: 8.0.8"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, addressed the copy in the latest commit.

}
if len(h.proxy.Denylist.Versions(canonicalPackagePURL("npm", packageName))) != 0 {
return nil, errors.New("npm metadata has no versions object")
}
Expand Down Expand Up @@ -612,15 +757,15 @@ func (h *NPMHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
if h.versionInCooldown(packageName, version, metadata) {
h.proxy.Logger.Info("cooldown: withholding npm tarball",
"package", packageName, "version", version)
JSONError(w, http.StatusNotFound, "version not found")
JSONError(w, http.StatusNotFound, ErrVersionNotFound.Error())
return
}

result, err := h.getTarball(r, registryURL, packageName, version, filename, metadata)
if err != nil {
switch {
case errors.Is(err, ErrUpstreamNotFound):
JSONError(w, http.StatusNotFound, "package not found")
JSONError(w, http.StatusNotFound, ErrPackageNotFound.Error())
case errors.Is(err, ErrArtifactBlocked), errors.Is(err, ErrVersionDenied):
JSONError(w, http.StatusForbidden, err.Error())
default:
Expand Down Expand Up @@ -746,6 +891,31 @@ func (h *NPMHandler) versionInCooldown(packageName, version string, metadata fun
return !h.proxy.Cooldown.IsAllowed("npm", canonicalPackagePURL("npm", packageName), publishedAt)
}

// npmMetadataPath returns the package name and the encoded registry path.
// Examples: lodash → lodash; lodash/4.17.21 → lodash/4.17.21;
// @babel/core → @babel%2Fcore; @babel/core/7.23.0 → @babel%2Fcore/7.23.0.
func npmMetadataPath(packagePath string) (packageName, registryPath string) {
// GET /npm/ has no package segment; handlePackageMetadata rejects that earlier.
if packagePath == "" {
return "", ""
}
if !strings.Contains(packagePath, "/") {
return packagePath, url.PathEscape(packagePath)
}
if strings.HasPrefix(packagePath, "@") {
scope, rest, _ := strings.Cut(packagePath, "/")
name, version, ok := strings.Cut(rest, "/")
packageName = scope + "/" + name
registryPath = url.PathEscape(scope) + "%2F" + url.PathEscape(name)
if ok {
registryPath += "/" + url.PathEscape(version)
}
return packageName, registryPath
}
name, version, _ := strings.Cut(packagePath, "/")
return name, url.PathEscape(name) + "/" + url.PathEscape(version)
}

func escapeNPMDownloadPackage(packageName string) string {
scope, name, scoped := strings.Cut(packageName, "/")
if scoped && strings.HasPrefix(scope, "@") && len(scope) > 1 && name != "" && !strings.Contains(name, "/") {
Expand Down
Loading
Loading