Fail closed in gem, Hex and PyPI cooldown
RubyGems, Hex and PyPI keep publish times outside the metadata clients
read, so cooldown fetches them from a second API. When that fetch
failed, was rate limited, or returned something that didn't parse, the
compact index, Hex package or simple page was served unfiltered, and a
Hex package that couldn't be re-encoded was passed on as upstream sent
it. A flaky or rate-limited timestamp API let every version in
cooldown through.
These now fail with 502, or with 503 and Retry-After when the rate
limit gate held the request, as the Go handler already does. PyPI file
downloads check the same way. A PyPI package the JSON API answers 404
for still has no times and is served unfiltered, as before.
npm, Composer, pub.dev and PyPI JSON metadata that couldn't be
rewritten was also passed on unfiltered unless the package had denied
versions. It now fails with 502 whenever cooldown applies too. npm
tarball downloads whose packument couldn't be fetched skipped the
cooldown check and downloaded straight from upstream; they now fail.
All of this only applies when cooldown covers the package: packages
exempted with a duration of 0 skip the timestamp lookups, and the gem
and Hex handlers serve them through the metadata cache as when cooldown
is off.
2026-10-24 08:11:25 +02:00
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
import "net/http"
|
|
|
|
|
|
|
|
|
|
// cooldownApplies reports whether cooldown withholds new versions of the
|
|
|
|
|
// package, taking package overrides and exemptions into account.
|
|
|
|
|
func (p *Proxy) cooldownApplies(ecosystem, name string) bool {
|
2026-10-31 09:49:35 +00:00
|
|
|
return p.cooldownEnabled() && p.cooldown().For(ecosystem, canonicalPackagePURL(ecosystem, name)) > 0
|
Fail closed in gem, Hex and PyPI cooldown
RubyGems, Hex and PyPI keep publish times outside the metadata clients
read, so cooldown fetches them from a second API. When that fetch
failed, was rate limited, or returned something that didn't parse, the
compact index, Hex package or simple page was served unfiltered, and a
Hex package that couldn't be re-encoded was passed on as upstream sent
it. A flaky or rate-limited timestamp API let every version in
cooldown through.
These now fail with 502, or with 503 and Retry-After when the rate
limit gate held the request, as the Go handler already does. PyPI file
downloads check the same way. A PyPI package the JSON API answers 404
for still has no times and is served unfiltered, as before.
npm, Composer, pub.dev and PyPI JSON metadata that couldn't be
rewritten was also passed on unfiltered unless the package had denied
versions. It now fails with 502 whenever cooldown applies too. npm
tarball downloads whose packument couldn't be fetched skipped the
cooldown check and downloaded straight from upstream; they now fail.
All of this only applies when cooldown covers the package: packages
exempted with a duration of 0 skip the timestamp lookups, and the gem
and Hex handlers serve them through the metadata cache as when cooldown
is off.
2026-10-24 08:11:25 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// mustFilterMetadata reports whether the package's metadata may have
|
|
|
|
|
// versions to withhold, by cooldown or by the denylist, and so must not be
|
|
|
|
|
// served as upstream sent it.
|
|
|
|
|
func (p *Proxy) mustFilterMetadata(ecosystem, name string) bool {
|
|
|
|
|
return p.cooldownApplies(ecosystem, name) ||
|
|
|
|
|
len(p.Denylist.Versions(canonicalPackagePURL(ecosystem, name))) != 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// writeCooldownLookupError answers a metadata request whose publish times
|
|
|
|
|
// couldn't be read: 503 with Retry-After when the rate limit gate held the
|
|
|
|
|
// request, 502 otherwise.
|
|
|
|
|
func (p *Proxy) writeCooldownLookupError(w http.ResponseWriter, ecosystem, name string, err error) {
|
|
|
|
|
if writeUpstreamHeld(w, err) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
p.Logger.Warn("cooldown: could not read publish times",
|
|
|
|
|
"ecosystem", ecosystem, "package", name, "error", err)
|
|
|
|
|
http.Error(w, "failed to check cooldown", http.StatusBadGateway)
|
|
|
|
|
}
|
2026-10-25 12:31:33 +01:00
|
|
|
|
|
|
|
|
// serveCachedDespiteCooldownError serves a download from the cache when its
|
|
|
|
|
// publish time couldn't be read, and reports whether it did. A cached file was
|
|
|
|
|
// already let through once, so the proxy keeps serving it while the upstream
|
|
|
|
|
// is unreachable; only a cache miss fails.
|
|
|
|
|
func (p *Proxy) serveCachedDespiteCooldownError(w http.ResponseWriter, r *http.Request, ecosystem, name, version, filename string, lookupErr error) bool {
|
|
|
|
|
if p.versionDenied(ecosystem, name, version) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
cached, err := p.GetCachedArtifact(r.Context(), ecosystem, name, version, filename)
|
|
|
|
|
if err != nil || cached == nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
p.Logger.Warn("cooldown: could not read publish times, serving cached file",
|
|
|
|
|
"ecosystem", ecosystem, "package", name, "version", version, "error", lookupErr)
|
|
|
|
|
ServeArtifactRequest(w, r, cached)
|
|
|
|
|
return true
|
|
|
|
|
}
|