Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-09-17 00:02:04 -04:00
pkg-proxy/internal/handler/nuget_cooldown.go
Abhinav Gautam c895e5b053
Fix NuGet cooldown enforcement for listings and downloads (#340)
* fix(nuget): enforce cooldown across metadata and downloads

* fix(nuget): support legacy registration and preserve valid metadata cache

* refactor(nuget): address maintainer review cleanup
2026-09-16 08:27:44 +01:00

374 lines
11 KiB
Go

package handler
import (
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"slices"
"strings"
"time"
)
var nugetRegistrationPrefixes = []string{
"/v3/registration5-semver1/",
"/v3/registration5-gz-semver1/",
"/v3/registration5-gz-semver2/",
}
var nugetArtifactPrefixes = append([]string{"/v3-flatcontainer/"}, nugetRegistrationPrefixes...)
const nugetRegistrationPath = "/v3/registration5-gz-semver2/"
func (h *NuGetHandler) cooldownEnabled() bool {
return h.proxy.Cooldown != nil && h.proxy.Cooldown.Enabled()
}
func (h *NuGetHandler) nugetCooldownApplies(id string) bool {
return h.cooldownEnabled() && h.proxy.Cooldown.For("nuget", canonicalPackagePURL("nuget", strings.ToLower(id))) > 0
}
// Cache upstream documents, not filtered results, so policy changes and elapsed
// time take effect even while metadata is fresh. Include the upstream in the key.
func (h *NuGetHandler) nugetMetadata(ctx context.Context, path string) (map[string]any, error) {
target := h.upstreamURL + path
key := fmt.Sprintf("_cooldown/%x", sha256.Sum256([]byte(target)))
var document map[string]any
validate := func(body []byte) error {
var err error
document, err = h.decodeNuGetMetadata(body)
return err
}
_, _, _, err := h.proxy.fetchOrCacheMetadata(ctx, "nuget", key, target, "", validate)
if err != nil {
return nil, err
}
return document, nil
}
func (h *NuGetHandler) decodeNuGetMetadata(body []byte) (map[string]any, error) {
// Normally the HTTP transport decodes gzip. Also support compressed cached
// bytes and clients with transparent decompression disabled, with the same
// metadata limit applied to the decompressed document.
if bytes.HasPrefix(body, []byte{0x1f, 0x8b}) {
reader, err := gzip.NewReader(bytes.NewReader(body))
if err != nil {
return nil, err
}
defer func() { _ = reader.Close() }()
body, err = h.proxy.ReadMetadata(reader)
if err != nil {
return nil, err
}
}
var document map[string]any
if err := json.Unmarshal(body, &document); err != nil {
return nil, fmt.Errorf("parsing NuGet metadata: %w", err)
}
if document == nil {
return nil, fmt.Errorf("empty NuGet metadata")
}
return document, nil
}
// Prefer semver2, but a configured source may advertise only an older hive.
// Retry only advertised aliases on 404; transport/validation errors must not
// silently switch to a hive with less complete metadata. Keep requests on the
// configured upstream, consistent with the service-index route rewriting.
func (h *NuGetHandler) nugetRegistrationMetadata(ctx context.Context, suffix string) (map[string]any, string, error) {
path := nugetRegistrationPath + suffix
document, err := h.nugetMetadata(ctx, path)
if !errors.Is(err, ErrUpstreamNotFound) {
return document, path, err
}
index, indexErr := h.nugetMetadata(ctx, "/v3/index.json")
if indexErr != nil {
return nil, path, indexErr
}
resources, _ := index["resources"].([]any)
seen := map[string]bool{nugetRegistrationPath: true}
for _, resource := range resources {
entry, _ := resource.(map[string]any)
service, _ := entry["@type"].(string)
id, _ := entry["@id"].(string)
if id == "" || !strings.HasPrefix(service, "RegistrationsBaseUrl") {
continue
}
prefix := strings.TrimPrefix(h.rewriteNuGetURL(id, service), h.proxyURL+"/nuget")
if !slices.Contains(nugetRegistrationPrefixes, prefix) || seen[prefix] {
continue
}
seen[prefix] = true
path = prefix + suffix
document, err = h.nugetMetadata(ctx, path)
if !errors.Is(err, ErrUpstreamNotFound) {
return document, path, err
}
}
return nil, path, err
}
func (h *NuGetHandler) nugetMetadataError(w http.ResponseWriter, err error) {
if errors.Is(err, ErrUpstreamNotFound) {
JSONError(w, http.StatusNotFound, "package metadata not found")
return
}
h.proxy.Logger.Warn("failed to process NuGet metadata", "error", err)
JSONError(w, http.StatusBadGateway, "failed to process package metadata")
}
func (h *NuGetHandler) handleVersionList(w http.ResponseWriter, r *http.Request) {
if !h.cooldownEnabled() {
h.proxyUpstream(w, r)
return
}
id := strings.ToLower(r.PathValue("id"))
document, err := h.nugetMetadata(r.Context(), "/v3-flatcontainer/"+url.PathEscape(id)+"/index.json")
if err != nil {
h.nugetMetadataError(w, err)
return
}
blocked := make(map[string]bool)
// A globally enabled policy may still exempt this package or ecosystem.
// Keep metadata caching, but do not require publication data in that case.
if h.nugetCooldownApplies(id) {
registration, registrationPath, err := h.nugetRegistrationMetadata(r.Context(), url.PathEscape(id)+"/index.json")
if err == nil {
err = h.expandNuGetPages(r.Context(), registration, registrationPath)
}
if err != nil {
h.nugetMetadataError(w, err)
return
}
h.collectNuGetBlockedVersions(registration, id, blocked)
}
versions, ok := document["versions"].([]any)
if !ok {
h.nugetMetadataError(w, fmt.Errorf("missing NuGet versions"))
return
}
filtered := make([]any, 0, len(versions))
for _, value := range versions {
version, ok := value.(string)
if !ok {
h.nugetMetadataError(w, fmt.Errorf("invalid NuGet version"))
return
}
if !blocked[nugetVersionKey(version)] {
filtered = append(filtered, value)
}
}
document["versions"] = filtered
w.Header().Set(headerContentType, contentTypeJSON)
_ = json.NewEncoder(w).Encode(document)
}
func nugetVersionKey(version string) string {
version, _, _ = strings.Cut(version, "+")
return strings.ToLower(version)
}
func (h *NuGetHandler) nugetDownloadAllowed(ctx context.Context, id, version string) (bool, error) {
if !h.nugetCooldownApplies(id) {
return true, nil
}
suffix := url.PathEscape(strings.ToLower(id)) + "/" + url.PathEscape(nugetVersionKey(version)) + ".json"
leaf, _, err := h.nugetRegistrationMetadata(ctx, suffix)
if err != nil {
return false, err
}
return h.nugetLeafAllowed(leaf, id), nil
}
// A standalone leaf has published at its root; leaves embedded in pages carry
// it in catalogEntry. Missing/invalid timestamps retain the existing permissive
// behavior, but fetch and JSON errors must not bypass the policy.
func (h *NuGetHandler) nugetLeafAllowed(leaf map[string]any, id string) bool {
if !h.cooldownEnabled() {
return true
}
entry := nugetCatalogEntry(leaf)
if id == "" {
id, _ = entry["id"].(string)
}
published, _ := entry["published"].(string)
when, err := time.Parse(time.RFC3339, published)
if err != nil {
return true
}
return h.proxy.Cooldown.IsAllowed("nuget", canonicalPackagePURL("nuget", strings.ToLower(id)), when)
}
func nugetCatalogEntry(leaf map[string]any) map[string]any {
if entry, ok := leaf["catalogEntry"].(map[string]any); ok {
return entry
}
return leaf
}
func (h *NuGetHandler) collectNuGetBlockedVersions(document map[string]any, id string, blocked map[string]bool) {
entry := nugetCatalogEntry(document)
if version, ok := entry["version"].(string); ok && !h.nugetLeafAllowed(document, id) {
blocked[nugetVersionKey(version)] = true
}
items, _ := document["items"].([]any)
for _, item := range items {
if child, ok := item.(map[string]any); ok {
h.collectNuGetBlockedVersions(child, id, blocked)
}
}
}
func (h *NuGetHandler) handleRegistration(w http.ResponseWriter, r *http.Request) {
if !h.cooldownEnabled() {
h.proxyUpstream(w, r)
return
}
id := nugetRegistrationID(r.URL.Path)
applyCooldown := h.nugetCooldownApplies(id)
document, err := h.nugetMetadata(r.Context(), r.URL.Path)
if err == nil && applyCooldown {
err = h.expandNuGetPages(r.Context(), document, r.URL.Path)
}
if err != nil {
h.nugetMetadataError(w, err)
return
}
_, hasItems := document["items"]
if applyCooldown && !h.filterNuGetRegistration(document, id) && !hasItems {
JSONError(w, http.StatusNotFound, "version not found")
return
}
h.rewriteNuGetRegistrationLinks(document)
w.Header().Set(headerContentType, contentTypeJSON)
_ = json.NewEncoder(w).Encode(document)
}
func nugetRegistrationID(path string) string {
for _, prefix := range nugetRegistrationPrefixes {
if rest, ok := strings.CutPrefix(path, prefix); ok {
id, _, _ := strings.Cut(rest, "/")
return id
}
}
return ""
}
// Only expand index pages, never recursively follow arbitrary upstream links.
// Pin requests to this configured upstream and the current package's page path.
func (h *NuGetHandler) expandNuGetPages(ctx context.Context, document map[string]any, path string) error {
if !strings.HasSuffix(path, "/index.json") {
return nil
}
items, ok := document["items"].([]any)
if !ok {
return fmt.Errorf("missing registration pages")
}
base, err := url.Parse(h.upstreamURL + path)
if err != nil {
return err
}
pagePrefix := strings.TrimSuffix(base.Path, "index.json") + "page/"
for _, item := range items {
page, ok := item.(map[string]any)
if !ok {
return fmt.Errorf("invalid registration page")
}
if _, ok := page["items"].([]any); ok {
continue
}
link, _ := page["@id"].(string)
target, err := base.Parse(link)
if err != nil || target.Scheme != base.Scheme || target.Host != base.Host ||
!strings.HasPrefix(target.Path, pagePrefix) || containsPathTraversal(target.Path) || target.RawQuery != "" || target.Fragment != "" {
return fmt.Errorf("invalid registration page URL: %q", link)
}
upstream, _ := url.Parse(h.upstreamURL)
pageDocument, err := h.nugetMetadata(ctx, strings.TrimPrefix(target.Path, upstream.Path))
if err != nil {
return err
}
leaves, ok := pageDocument["items"].([]any)
if !ok {
return fmt.Errorf("missing registration leaves")
}
page["items"] = leaves
}
return nil
}
func (h *NuGetHandler) filterNuGetRegistration(document map[string]any, id string) bool {
items, ok := document["items"].([]any)
if !ok {
return h.nugetLeafAllowed(document, id)
}
filtered := make([]any, 0, len(items))
for _, item := range items {
child, ok := item.(map[string]any)
if ok && h.filterNuGetRegistration(child, id) {
filtered = append(filtered, child)
}
}
document["items"] = filtered
document["count"] = len(filtered)
// Page bounds describe the retained leaves, not versions hidden by cooldown.
if _, isPage := document["lower"]; isPage && len(filtered) > 0 {
first, _ := filtered[0].(map[string]any)
last, _ := filtered[len(filtered)-1].(map[string]any)
document["lower"] = nugetCatalogEntry(first)["version"]
document["upper"] = nugetCatalogEntry(last)["version"]
}
return len(filtered) > 0
}
func (h *NuGetHandler) rewriteNuGetRegistrationLinks(value any) {
switch node := value.(type) {
case map[string]any:
for key, child := range node {
if link, ok := child.(string); ok {
switch key {
case "@id", "parent", "registration", "packageContent":
node[key] = h.nugetProxyLink(link)
}
} else {
h.rewriteNuGetRegistrationLinks(child)
}
}
case []any:
for _, child := range node {
h.rewriteNuGetRegistrationLinks(child)
}
}
}
func (h *NuGetHandler) nugetProxyLink(link string) string {
u, err := url.Parse(link)
if err != nil {
return link
}
upstream, err := url.Parse(h.upstreamURL)
if err != nil {
return link
}
path := u.Path
if u.Host == upstream.Host {
path = strings.TrimPrefix(path, upstream.Path)
}
for _, prefix := range nugetArtifactPrefixes {
if strings.HasPrefix(path, prefix) {
proxy, err := url.Parse(h.proxyURL + "/nuget" + path)
if err != nil {
return link
}
proxy.RawQuery = u.RawQuery
proxy.Fragment = u.Fragment
return proxy.String()
}
}
return link
}