mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-11-10 08:09:17 -05:00
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.
377 lines
11 KiB
Go
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
|
|
}
|