Skip to content
Open
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
13 changes: 13 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,19 @@ Resolution order: package override, then ecosystem override, then global default

Currently supported for npm, PyPI, pub.dev, Composer, Cargo, NuGet, Conda, RubyGems, and Hex. These ecosystems include publish timestamps in their metadata.

For NuGet, cooldown filters flat-container version lists and registration metadata,
including separately fetched registration pages. Pinned package downloads are
checked before serving either upstream or cached files. Registration metadata is
cached unfiltered and evaluated against the current policy on each request. Missing
or invalid publication timestamps retain the permissive behavior used by metadata
filtering; metadata fetch or JSON parsing failures return an error when no usable
cached metadata is available.

If the semver2 registration endpoint returns not found, cooldown checks try the
older registration aliases advertised by the source's service index. Invalid JSON
or compressed metadata cannot replace a previously valid cached document; the
proxy falls back to that document and evaluates it against the current policy.

Note: Hex cooldown requires disabling registry signature verification since the proxy re-encodes the protobuf payload without the original signature. Set `HEX_NO_VERIFY_REPO_ORIGIN=1` or configure your repo with `no_verify: true`.

## Artifact Scanning
Expand Down
57 changes: 34 additions & 23 deletions internal/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -697,15 +697,17 @@ func metadataStoragePath(ecosystem, cacheKey string) string {
// cacheKey is typically the package name but can include subpath components.
// Optional acceptHeaders specify the Accept header(s) to send; defaults to application/json.
func (p *Proxy) FetchOrCacheMetadata(ctx context.Context, ecosystem, cacheKey, upstreamURL string, acceptHeaders ...string) ([]byte, string, error) {
return p.fetchOrCacheMetadata(ctx, ecosystem, cacheKey, upstreamURL, false, acceptHeaders...)
return p.fetchOrCacheMetadata(ctx, ecosystem, cacheKey, upstreamURL, false, nil, acceptHeaders...)
}

// fetchOrCacheMetadata implements FetchOrCacheMetadata. When verbatim is true
// (the ProxyCached path, which serves upstream bytes through unchanged) the
// upstream is fetched with Accept-Encoding: identity so signed and hash-pinned
// index files are cached exactly as sent. Direct callers that parse or rewrite
// the body pass verbatim=false and keep transparent transfer compression.
func (p *Proxy) fetchOrCacheMetadata(ctx context.Context, ecosystem, cacheKey, upstreamURL string, verbatim bool, acceptHeaders ...string) ([]byte, string, error) {
// validate, when supplied, runs before caching or serving a document. Validation
// failures follow the same stale-cache fallback path as upstream failures.
func (p *Proxy) fetchOrCacheMetadata(ctx context.Context, ecosystem, cacheKey, upstreamURL string, verbatim bool, validate func([]byte) error, acceptHeaders ...string) ([]byte, string, error) {
if containsPathTraversal(cacheKey) {
return nil, "", fmt.Errorf("invalid cache key: %q", cacheKey)
}
Expand All @@ -721,18 +723,14 @@ func (p *Proxy) fetchOrCacheMetadata(ctx context.Context, ecosystem, cacheKey, u
// Serve from cache if within TTL (skip upstream entirely)
if entry != nil && p.MetadataTTL > 0 && entry.FetchedAt.Valid {
if time.Since(entry.FetchedAt.Time) < p.MetadataTTL {
cached, readErr := p.Storage.Open(ctx, entry.StoragePath)
data, ct, readErr := p.readCachedMetadata(ctx, entry, validate)
if readErr == nil {
defer func() { _ = cached.Close() }()
data, readErr := p.ReadMetadata(cached)
if readErr == nil {
ct := contentTypeJSON
if entry.ContentType.Valid {
ct = entry.ContentType.String
}
metrics.RecordCacheHit(ecosystem)
return data, ct, nil
}
metrics.RecordCacheHit(ecosystem)
return data, ct, nil
}
if validate != nil {
// Do not revalidate an unusable cached body with its ETag.
entry = nil
}
// Cache file missing/unreadable, fall through to upstream
}
Expand All @@ -750,6 +748,9 @@ func (p *Proxy) fetchOrCacheMetadata(ctx context.Context, ecosystem, cacheKey, u
// 304 but cached file is gone; retry without ETag
meta, err = p.fetchUpstreamMetadata(ctx, upstreamURL, nil, accept, verbatim)
}
if err == nil && validate != nil {
err = validate(meta.body)
}
if err == nil {
if p.CacheMetadata {
p.cacheMetadataBlob(ctx, ecosystem, cacheKey, storagePath, meta)
Expand All @@ -765,23 +766,33 @@ func (p *Proxy) fetchOrCacheMetadata(ctx context.Context, ecosystem, cacheKey, u
p.Logger.Warn("upstream metadata fetch failed, checking cache",
"ecosystem", ecosystem, "key", cacheKey, "error", err)

cached, readErr := p.Storage.Open(ctx, entry.StoragePath)
data, ct, readErr := p.readCachedMetadata(ctx, entry, validate)
if readErr != nil {
return nil, "", fmt.Errorf("upstream failed and cached file missing: %w", err)
return nil, "", fmt.Errorf("upstream failed and cached metadata unusable (%v): %w", readErr, err)
}
defer func() { _ = cached.Close() }()

data, readErr := p.ReadMetadata(cached)
if readErr != nil {
return nil, "", fmt.Errorf("upstream failed and cached read error: %w", err)
}
p.Logger.Info("serving metadata from cache",
"ecosystem", ecosystem, "key", cacheKey)
return data, ct, nil
}

func (p *Proxy) readCachedMetadata(ctx context.Context, entry *database.MetadataCacheEntry, validate func([]byte) error) ([]byte, string, error) {
cached, err := p.Storage.Open(ctx, entry.StoragePath)
if err != nil {
return nil, "", err
}
defer func() { _ = cached.Close() }()
data, err := p.ReadMetadata(cached)
if err == nil && validate != nil {
err = validate(data)
}
if err != nil {
return nil, "", err
}
ct := contentTypeJSON
if entry.ContentType.Valid {
ct = entry.ContentType.String
}
p.Logger.Info("serving metadata from cache",
"ecosystem", ecosystem, "key", cacheKey)
return data, ct, nil
}

Expand Down Expand Up @@ -952,7 +963,7 @@ func (p *Proxy) ProxyCached(w http.ResponseWriter, r *http.Request, upstreamURL,
return
}

body, contentType, err := p.fetchOrCacheMetadata(r.Context(), ecosystem, cacheKey, upstreamURL, true, acceptHeaders...)
body, contentType, err := p.fetchOrCacheMetadata(r.Context(), ecosystem, cacheKey, upstreamURL, true, nil, acceptHeaders...)
if err != nil {
if errors.Is(err, ErrUpstreamNotFound) {
http.Error(w, "not found", http.StatusNotFound)
Expand Down
161 changes: 24 additions & 137 deletions internal/handler/nuget.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"io"
"net/http"
"strings"
"time"
)

const (
Expand Down Expand Up @@ -51,10 +50,12 @@ func (h *NuGetHandler) Routes() http.Handler {

// Package content (downloads)
mux.HandleFunc("GET /v3-flatcontainer/{id}/{version}/{filename}", h.handleDownload)
mux.HandleFunc("GET /v3-flatcontainer/{id}/index.json", h.proxyUpstream)
mux.HandleFunc("GET /v3-flatcontainer/{id}/index.json", h.handleVersionList)

// Registration (package metadata) - use prefix matching since {version}.json isn't allowed
mux.HandleFunc("GET /v3/registration5-gz-semver2/", h.handleRegistration)
for _, prefix := range nugetRegistrationPrefixes {
mux.HandleFunc("GET "+prefix, h.handleRegistration)
}

// Search
mux.HandleFunc("GET /query", h.proxyUpstream)
Expand Down Expand Up @@ -84,6 +85,10 @@ func (h *NuGetHandler) handleServiceIndex(w http.ResponseWriter, r *http.Request

rewritten, err := h.rewriteServiceIndex(body)
if err != nil {
if h.cooldownEnabled() {
h.nugetMetadataError(w, err)
return
}
h.proxy.Logger.Warn("failed to rewrite service index, proxying original", "error", err)
w.Header().Set(headerContentType, "application/json")
_, _ = w.Write(body)
Expand Down Expand Up @@ -131,6 +136,10 @@ func (h *NuGetHandler) rewriteNuGetURL(origURL, serviceType string) string {
switch serviceType {
case "PackageBaseAddress/3.0.0":
return h.proxyURL + "/nuget/v3-flatcontainer/"
case "RegistrationsBaseUrl", "RegistrationsBaseUrl/3.0.0-beta", "RegistrationsBaseUrl/3.0.0-rc":
return h.proxyURL + "/nuget/v3/registration5-semver1/"
case "RegistrationsBaseUrl/3.4.0":
return h.proxyURL + "/nuget/v3/registration5-gz-semver1/"
case "RegistrationsBaseUrl/3.6.0", "RegistrationsBaseUrl/Versioned":
return h.proxyURL + "/nuget/v3/registration5-gz-semver2/"
case "SearchQueryService", "SearchQueryService/3.0.0-rc", "SearchQueryService/3.5.0":
Expand All @@ -142,140 +151,6 @@ func (h *NuGetHandler) rewriteNuGetURL(origURL, serviceType string) string {
}
}

// handleRegistration proxies NuGet registration pages, applying cooldown filtering.
func (h *NuGetHandler) handleRegistration(w http.ResponseWriter, r *http.Request) {
if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() {
h.proxyUpstream(w, r)
return
}

upstreamURL := h.buildUpstreamURL(r)

h.proxy.Logger.Debug("fetching registration for cooldown filtering", "url", upstreamURL)

req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
if err != nil {
http.Error(w, "failed to create request", http.StatusInternalServerError)
return
}
req.Header.Set(headerAcceptEncoding, "gzip")

resp, err := h.proxy.HTTPClient.Do(req)
if err != nil {
h.proxy.Logger.Error("upstream request failed", "error", err)
http.Error(w, "upstream request failed", http.StatusBadGateway)
return
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
for k, vv := range resp.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
return
}

body, err := h.proxy.ReadMetadata(resp.Body)
if err != nil {
http.Error(w, "failed to read response", http.StatusInternalServerError)
return
}

filtered, err := h.applyCooldownFiltering(body)
if err != nil {
h.proxy.Logger.Warn("failed to filter registration, proxying original", "error", err)
w.Header().Set(headerContentType, "application/json")
_, _ = w.Write(body)
return
}

w.Header().Set(headerContentType, "application/json")
_, _ = w.Write(filtered)
}

// applyCooldownFiltering filters versions from NuGet registration pages
// that are too recently published.
func (h *NuGetHandler) applyCooldownFiltering(body []byte) ([]byte, error) {
if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() {
return body, nil
}

var registration map[string]any
if err := json.Unmarshal(body, &registration); err != nil {
return nil, err
}

pages, ok := registration["items"].([]any)
if !ok {
return body, nil
}

for _, page := range pages {
pageMap, ok := page.(map[string]any)
if !ok {
continue
}

items, ok := pageMap["items"].([]any)
if !ok {
continue
}

filtered := items[:0]
for _, item := range items {
itemMap, ok := item.(map[string]any)
if !ok {
continue
}

catalogEntry, ok := itemMap["catalogEntry"].(map[string]any)
if !ok {
filtered = append(filtered, item)
continue
}

version, _ := catalogEntry["version"].(string)
id, _ := catalogEntry["id"].(string)
publishedStr, _ := catalogEntry["published"].(string)

if publishedStr == "" {
filtered = append(filtered, item)
continue
}

publishedAt, err := time.Parse(time.RFC3339, publishedStr)
if err != nil {
// NuGet uses a slightly non-standard format, try parsing with fractional seconds
publishedAt, err = time.Parse("2006-01-02T15:04:05.999-07:00", publishedStr)
if err != nil {
filtered = append(filtered, item)
continue
}
}

packagePURL := canonicalPackagePURL("nuget", strings.ToLower(id))

if !h.proxy.Cooldown.IsAllowed("nuget", packagePURL, publishedAt) {
h.proxy.Logger.Info("cooldown: filtering nuget version",
"package", id, "version", version,
"published", publishedStr)
continue
}

filtered = append(filtered, item)
}

pageMap["items"] = filtered
pageMap["count"] = len(filtered)
}

return json.Marshal(registration)
}

// handleDownload serves a package file, fetching and caching from upstream if needed.
func (h *NuGetHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
Expand All @@ -287,6 +162,18 @@ func (h *NuGetHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
return
}

if h.cooldownEnabled() {
allowed, err := h.nugetDownloadAllowed(r.Context(), id, version)
if err != nil {
h.nugetMetadataError(w, err)
return
}
if !allowed {
JSONError(w, http.StatusNotFound, "version not found")
return
}
}

// Only cache .nupkg files
if !strings.HasSuffix(filename, ".nupkg") {
h.proxyUpstream(w, r)
Expand Down
Loading