Skip to content

Commit 70fe686

Browse files
authored
Add cooldown support for NuGet (#67)
* Add cooldown support for NuGet Filter versions from NuGet registration pages based on the catalogEntry.published timestamp. Handles both RFC3339 and NuGet's fractional-second timestamp formats. When cooldown is disabled, registration requests are proxied directly without parsing. * Update README table to mark NuGet cooldown support
1 parent 24d5e77 commit 70fe686

4 files changed

Lines changed: 473 additions & 3 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ Currently works with npm, PyPI, pub.dev, Composer, and Cargo, which all include
3434
| pub.dev | Dart | Yes | ✓ |
3535
| PyPI | Python | Yes | ✓ |
3636
| Maven | Java | | ✓ |
37-
| NuGet | .NET | | ✓ |
37+
| NuGet | .NET | Yes | ✓ |
3838
| Composer | PHP | Yes | ✓ |
3939
| Conan | C/C++ | | ✓ |
4040
| Conda | Python/R | | ✓ |

docs/configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ Durations support days (`7d`), hours (`48h`), and minutes (`30m`). Set to `0` to
209209

210210
Resolution order: package override, then ecosystem override, then global default. This lets you set a conservative default while exempting trusted packages.
211211

212-
Currently supported for npm, PyPI, pub.dev, and Composer. These ecosystems include publish timestamps in their metadata. Other ecosystems (Go, Cargo, RubyGems) would require extra API calls and are not yet supported.
212+
Currently supported for npm, PyPI, pub.dev, Composer, Cargo, and NuGet. These ecosystems include publish timestamps in their metadata.
213213

214214
## Docker
215215

internal/handler/nuget.go

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import (
66
"io"
77
"net/http"
88
"strings"
9+
"time"
10+
11+
"github.com/git-pkgs/purl"
912
)
1013

1114
const (
@@ -40,7 +43,7 @@ func (h *NuGetHandler) Routes() http.Handler {
4043
mux.HandleFunc("GET /v3-flatcontainer/{id}/index.json", h.proxyUpstream)
4144

4245
// Registration (package metadata) - use prefix matching since {version}.json isn't allowed
43-
mux.HandleFunc("GET /v3/registration5-gz-semver2/", h.proxyUpstream)
46+
mux.HandleFunc("GET /v3/registration5-gz-semver2/", h.handleRegistration)
4447

4548
// Search
4649
mux.HandleFunc("GET /query", h.proxyUpstream)
@@ -167,6 +170,140 @@ func (h *NuGetHandler) rewriteNuGetURL(origURL string) string {
167170
return origURL
168171
}
169172

173+
// handleRegistration proxies NuGet registration pages, applying cooldown filtering.
174+
func (h *NuGetHandler) handleRegistration(w http.ResponseWriter, r *http.Request) {
175+
if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() {
176+
h.proxyUpstream(w, r)
177+
return
178+
}
179+
180+
upstreamURL := h.buildUpstreamURL(r)
181+
182+
h.proxy.Logger.Debug("fetching registration for cooldown filtering", "url", upstreamURL)
183+
184+
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
185+
if err != nil {
186+
http.Error(w, "failed to create request", http.StatusInternalServerError)
187+
return
188+
}
189+
req.Header.Set("Accept-Encoding", "gzip")
190+
191+
resp, err := h.proxy.HTTPClient.Do(req)
192+
if err != nil {
193+
h.proxy.Logger.Error("upstream request failed", "error", err)
194+
http.Error(w, "upstream request failed", http.StatusBadGateway)
195+
return
196+
}
197+
defer func() { _ = resp.Body.Close() }()
198+
199+
if resp.StatusCode != http.StatusOK {
200+
for k, vv := range resp.Header {
201+
for _, v := range vv {
202+
w.Header().Add(k, v)
203+
}
204+
}
205+
w.WriteHeader(resp.StatusCode)
206+
_, _ = io.Copy(w, resp.Body)
207+
return
208+
}
209+
210+
body, err := ReadMetadata(resp.Body)
211+
if err != nil {
212+
http.Error(w, "failed to read response", http.StatusInternalServerError)
213+
return
214+
}
215+
216+
filtered, err := h.applyCooldownFiltering(body)
217+
if err != nil {
218+
h.proxy.Logger.Warn("failed to filter registration, proxying original", "error", err)
219+
w.Header().Set("Content-Type", "application/json")
220+
_, _ = w.Write(body)
221+
return
222+
}
223+
224+
w.Header().Set("Content-Type", "application/json")
225+
_, _ = w.Write(filtered)
226+
}
227+
228+
// applyCooldownFiltering filters versions from NuGet registration pages
229+
// that are too recently published.
230+
func (h *NuGetHandler) applyCooldownFiltering(body []byte) ([]byte, error) {
231+
if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() {
232+
return body, nil
233+
}
234+
235+
var registration map[string]any
236+
if err := json.Unmarshal(body, &registration); err != nil {
237+
return nil, err
238+
}
239+
240+
pages, ok := registration["items"].([]any)
241+
if !ok {
242+
return body, nil
243+
}
244+
245+
for _, page := range pages {
246+
pageMap, ok := page.(map[string]any)
247+
if !ok {
248+
continue
249+
}
250+
251+
items, ok := pageMap["items"].([]any)
252+
if !ok {
253+
continue
254+
}
255+
256+
filtered := items[:0]
257+
for _, item := range items {
258+
itemMap, ok := item.(map[string]any)
259+
if !ok {
260+
continue
261+
}
262+
263+
catalogEntry, ok := itemMap["catalogEntry"].(map[string]any)
264+
if !ok {
265+
filtered = append(filtered, item)
266+
continue
267+
}
268+
269+
version, _ := catalogEntry["version"].(string)
270+
id, _ := catalogEntry["id"].(string)
271+
publishedStr, _ := catalogEntry["published"].(string)
272+
273+
if publishedStr == "" {
274+
filtered = append(filtered, item)
275+
continue
276+
}
277+
278+
publishedAt, err := time.Parse(time.RFC3339, publishedStr)
279+
if err != nil {
280+
// NuGet uses a slightly non-standard format, try parsing with fractional seconds
281+
publishedAt, err = time.Parse("2006-01-02T15:04:05.999-07:00", publishedStr)
282+
if err != nil {
283+
filtered = append(filtered, item)
284+
continue
285+
}
286+
}
287+
288+
packagePURL := purl.MakePURLString("nuget", strings.ToLower(id), "")
289+
290+
if !h.proxy.Cooldown.IsAllowed("nuget", packagePURL, publishedAt) {
291+
h.proxy.Logger.Info("cooldown: filtering nuget version",
292+
"package", id, "version", version,
293+
"published", publishedStr)
294+
continue
295+
}
296+
297+
filtered = append(filtered, item)
298+
}
299+
300+
pageMap["items"] = filtered
301+
pageMap["count"] = len(filtered)
302+
}
303+
304+
return json.Marshal(registration)
305+
}
306+
170307
// handleDownload serves a package file, fetching and caching from upstream if needed.
171308
func (h *NuGetHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
172309
id := r.PathValue("id")

0 commit comments

Comments
 (0)