Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-10 08:09:17 -05:00
pkg-proxy/internal/handler/nuget_cooldown.go
Giles Westwood b2955ef458 Keep holds through late successes and away from the circuit breaker
A response arriving during a hold was sent before it began, so a late
success no longer releases the host; only one after the hold has ended
does.

Artifact fetches check RateLimitGate.Held before calling the fetcher, so
held cache misses fail with the HeldError without counting as failures
in the host's circuit breaker.

Every handler that answers an upstream fetch error with 502 now answers
a held request with 503 and Retry-After instead.

OnHold and RecordUpstreamRateLimitHold drop the unused duration.
2026-10-15 13:35:12 +01:00

377 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
}
if writeUpstreamHeld(w, err) {
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
}