mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-11-09 15:49:16 -05:00
`npm search` requests /-/v1/search?text=... . The path has no "/-/" after the leading slash is trimmed, so it fell through to the package metadata handler: upstream got /-/v1%2Fsearch without the query string, and the answer was cached under "-/v1/search", so every later search was served the first one's results. Search and /-/ping (used by `npm ping`, which lost its query string the same way) are now relayed to upstream uncached with their query strings, like the signing-keys endpoint. Packuments share one metadata cache row whatever format they were fetched in. Without cooldown the proxy asks for the abbreviated format, which has no "time" map. After cooldown was turned on, a still-fresh abbreviated packument kept being served from cache, and used as the stale copy when upstream failed, so cooldown filtered nothing and the tarball check let recent versions through. When full metadata is required (cooldown or npm_full_metadata on), packument lookups now pass a validator that rejects an abbreviated document (top-level "modified", no "time"), so it counts as a cache miss, is refetched in full and is never used as a stale fallback. Behaviour without cooldown is unchanged. fetchMetadataDocument takes the validate callback that fetchOrCacheMetadata already has; other callers pass nil.
355 lines
12 KiB
Go
355 lines
12 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"path"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
chefEcosystem = "chef"
|
|
chefMountPath = "/chef"
|
|
chefAPIPath = "/api/v1"
|
|
// chefUpstreamKeyLength is how much of the upstream URL's digest scopes
|
|
// metadata cache keys and archive filenames: enough to keep two upstreams
|
|
// apart.
|
|
chefUpstreamKeyLength = 16
|
|
)
|
|
|
|
var (
|
|
// chefCookbookName matches Supermarket cookbook names: letters, digits,
|
|
// hyphens, underscores and dots, not starting with a dot.
|
|
chefCookbookName = regexp.MustCompile(`^[A-Za-z0-9_-][A-Za-z0-9_.-]*$`)
|
|
// chefVersion matches Chef cookbook versions, which have two or three
|
|
// numeric parts.
|
|
chefVersion = regexp.MustCompile(`^[0-9]+\.[0-9]+(\.[0-9]+)?$`)
|
|
|
|
errChefDocument = errors.New("unexpected Chef Supermarket document")
|
|
)
|
|
|
|
// ChefHandler serves a read-only Chef Supermarket at /chef. Berkshelf and
|
|
// Policyfiles resolve dependencies against /universe and then download each
|
|
// cookbook through the cookbook API. Every download location in those
|
|
// documents is rewritten to the proxy, so cookbooks are fetched through it and
|
|
// cached; dependency constraints and every other field pass through unchanged.
|
|
type ChefHandler struct {
|
|
proxy *Proxy
|
|
proxyURL string
|
|
upstreamURL string
|
|
// upstreamKey identifies the upstream in cache keys, so pointing
|
|
// upstream.chef at another Supermarket does not serve the previous one's
|
|
// documents or archives.
|
|
upstreamKey string
|
|
}
|
|
|
|
// NewChefHandler creates a Chef Supermarket handler for the Supermarket at
|
|
// upstreamURL, such as https://supermarket.chef.io or a private Supermarket.
|
|
func NewChefHandler(proxy *Proxy, proxyURL, upstreamURL string) *ChefHandler {
|
|
upstreamURL = strings.TrimSuffix(upstreamURL, "/")
|
|
digest := sha256.Sum256([]byte(upstreamURL))
|
|
return &ChefHandler{
|
|
proxy: proxy,
|
|
proxyURL: strings.TrimSuffix(proxyURL, "/"),
|
|
upstreamURL: upstreamURL,
|
|
upstreamKey: hex.EncodeToString(digest[:])[:chefUpstreamKeyLength],
|
|
}
|
|
}
|
|
|
|
// Routes returns the HTTP handler for Chef Supermarket requests.
|
|
func (h *ChefHandler) Routes() http.Handler {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /universe", h.handleUniverse)
|
|
mux.HandleFunc("GET "+chefAPIPath+"/cookbooks/{name}", h.handleCookbook)
|
|
mux.HandleFunc("GET "+chefAPIPath+"/cookbooks/{name}/versions/{version}", h.handleVersion)
|
|
mux.HandleFunc("GET "+chefAPIPath+"/cookbooks/{name}/versions/{version}/download", h.handleDownload)
|
|
return mux
|
|
}
|
|
|
|
func (h *ChefHandler) handleUniverse(w http.ResponseWriter, r *http.Request) {
|
|
h.serveDocument(w, r, "universe", h.upstreamURL+"/universe", h.rewriteUniverse)
|
|
}
|
|
|
|
func (h *ChefHandler) handleCookbook(w http.ResponseWriter, r *http.Request) {
|
|
name, ok := chefCookbookFromRequest(r)
|
|
if !ok {
|
|
http.Error(w, "invalid cookbook name", http.StatusBadRequest)
|
|
return
|
|
}
|
|
h.serveDocument(w, r, "cookbooks/"+name, h.upstreamCookbookURL(name), func(body []byte) ([]byte, error) {
|
|
return h.rewriteCookbook(name, body)
|
|
})
|
|
}
|
|
|
|
func (h *ChefHandler) handleVersion(w http.ResponseWriter, r *http.Request) {
|
|
name, version, ok := chefVersionFromRequest(r)
|
|
if !ok {
|
|
http.Error(w, "invalid cookbook version", http.StatusBadRequest)
|
|
return
|
|
}
|
|
h.serveDocument(w, r, "cookbooks/"+name+"/versions/"+version, h.upstreamVersionURL(name, version), func(body []byte) ([]byte, error) {
|
|
return h.rewriteVersion(name, version, body)
|
|
})
|
|
}
|
|
|
|
// handleDownload serves a cookbook archive from the cache. An archive not
|
|
// cached yet is fetched through the Supermarket download endpoint, following
|
|
// the redirect it answers with. A cached archive is served without the
|
|
// upstream. Archives are cached per upstream, since two Supermarkets can
|
|
// publish different cookbooks under the same name and version.
|
|
func (h *ChefHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
|
|
name, version, ok := chefVersionFromRequest(r)
|
|
if !ok {
|
|
http.Error(w, "invalid cookbook version", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
result, err := h.proxy.GetOrFetchArtifactFromURL(
|
|
r.Context(), chefEcosystem, name, version, h.archiveFilename(name, version), h.upstreamVersionURL(name, version)+"/download")
|
|
if err != nil {
|
|
h.proxy.serveArtifactError(w, err, "failed to fetch cookbook")
|
|
return
|
|
}
|
|
if result.Artifact.MediaType == "" {
|
|
w.Header().Set(headerContentType, "application/gzip")
|
|
}
|
|
ServeArtifactRequest(w, r, result)
|
|
}
|
|
|
|
// serveDocument serves a JSON document from the metadata cache, which applies
|
|
// the metadata TTL and falls back to the stored copy while the upstream is
|
|
// unavailable, rewritten by rewrite.
|
|
func (h *ChefHandler) serveDocument(w http.ResponseWriter, r *http.Request, documentKey, upstreamURL string, rewrite func([]byte) ([]byte, error)) {
|
|
cacheKey := h.cacheKey(documentKey)
|
|
if stored, ok := h.proxy.storedRewrite(chefEcosystem, cacheKey, h.proxyURL, cacheKey); ok {
|
|
serveRewrittenMetadata(w, r, stored)
|
|
return
|
|
}
|
|
|
|
upstream := h.proxy.fetchMetadataDocument(r.Context(), chefEcosystem, cacheKey, upstreamURL, contentTypeJSON, nil)
|
|
if err := upstream.err; err != nil {
|
|
if errors.Is(err, ErrUpstreamNotFound) {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if writeUpstreamHeld(w, err) {
|
|
return
|
|
}
|
|
h.proxy.Logger.Error("Chef Supermarket request failed", "url", upstreamURL, "error", err)
|
|
http.Error(w, "upstream request failed", http.StatusBadGateway)
|
|
return
|
|
}
|
|
|
|
rewritten, err := h.proxy.cachedRewrite(r.Context(), chefEcosystem, h.proxyURL, cacheKey, upstream.body, upstream.digest,
|
|
func(body []byte) ([]byte, []string, error) {
|
|
out, err := rewrite(body)
|
|
return out, nil, err
|
|
})
|
|
if err != nil {
|
|
if r.Context().Err() != nil {
|
|
return // the client left while waiting on a shared rewrite
|
|
}
|
|
// Serving the document unrewritten would send clients straight to
|
|
// the upstream, bypassing the cache.
|
|
h.proxy.Logger.Warn("failed to rewrite Chef Supermarket document", "url", upstreamURL, "error", err)
|
|
http.Error(w, "invalid Chef Supermarket response", http.StatusBadGateway)
|
|
return
|
|
}
|
|
markStale(w, upstream.stale)
|
|
serveRewrittenMetadata(w, r, rewritten)
|
|
}
|
|
|
|
// rewriteUniverse points every version's location_path and download_url at
|
|
// the proxy. The universe maps cookbook names to versions to their location
|
|
// and dependencies; dependencies and any other field are copied as they are.
|
|
func (h *ChefHandler) rewriteUniverse(body []byte) ([]byte, error) {
|
|
var universe map[string]map[string]map[string]json.RawMessage
|
|
if err := json.Unmarshal(body, &universe); err != nil {
|
|
return nil, fmt.Errorf("%w: universe: %w", errChefDocument, err)
|
|
}
|
|
|
|
locationPath, err := jsonString(h.apiURL())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for name, versions := range universe {
|
|
for version, entry := range versions {
|
|
if entry == nil {
|
|
continue
|
|
}
|
|
downloadURL, err := jsonString(h.downloadURL(name, version))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
entry["location_path"] = locationPath
|
|
entry["download_url"] = downloadURL
|
|
}
|
|
}
|
|
return encodeChefJSON(universe)
|
|
}
|
|
|
|
// rewriteCookbook points a cookbook's version URLs at the proxy.
|
|
func (h *ChefHandler) rewriteCookbook(name string, body []byte) ([]byte, error) {
|
|
var cookbook map[string]json.RawMessage
|
|
if err := json.Unmarshal(body, &cookbook); err != nil {
|
|
return nil, fmt.Errorf("%w: cookbook %s: %w", errChefDocument, name, err)
|
|
}
|
|
|
|
if raw, ok := cookbook["latest_version"]; ok {
|
|
rewritten, err := h.rewriteVersionURL(name, raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cookbook["latest_version"] = rewritten
|
|
}
|
|
if raw, ok := cookbook["versions"]; ok {
|
|
var versions []json.RawMessage
|
|
if err := json.Unmarshal(raw, &versions); err != nil {
|
|
return nil, fmt.Errorf("%w: cookbook %s versions: %w", errChefDocument, name, err)
|
|
}
|
|
for i, versionURL := range versions {
|
|
rewritten, err := h.rewriteVersionURL(name, versionURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
versions[i] = rewritten
|
|
}
|
|
encoded, err := encodeChefJSON(versions)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cookbook["versions"] = encoded
|
|
}
|
|
return encodeChefJSON(cookbook)
|
|
}
|
|
|
|
// rewriteVersion points a cookbook version's cookbook and file URLs at the
|
|
// proxy.
|
|
func (h *ChefHandler) rewriteVersion(name, version string, body []byte) ([]byte, error) {
|
|
var document map[string]json.RawMessage
|
|
if err := json.Unmarshal(body, &document); err != nil {
|
|
return nil, fmt.Errorf("%w: cookbook %s version %s: %w", errChefDocument, name, version, err)
|
|
}
|
|
|
|
if _, ok := document["cookbook"]; ok {
|
|
cookbookURL, err := jsonString(h.cookbookURL(name))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
document["cookbook"] = cookbookURL
|
|
}
|
|
if _, ok := document["file"]; ok {
|
|
fileURL, err := jsonString(h.downloadURL(name, version))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
document["file"] = fileURL
|
|
}
|
|
return encodeChefJSON(document)
|
|
}
|
|
|
|
// rewriteVersionURL rewrites an upstream cookbook version URL, a JSON string
|
|
// ending in /versions/{version}, to the proxy. Anything else, such as null,
|
|
// is returned unchanged.
|
|
func (h *ChefHandler) rewriteVersionURL(name string, raw json.RawMessage) (json.RawMessage, error) {
|
|
var versionURL string
|
|
if err := json.Unmarshal(raw, &versionURL); err != nil {
|
|
return raw, nil //nolint:nilerr // not a string, so not a URL to rewrite
|
|
}
|
|
parsed, err := url.Parse(versionURL)
|
|
if err != nil {
|
|
return raw, nil //nolint:nilerr // not a URL, so nothing to rewrite
|
|
}
|
|
version, ok := normalizeChefVersion(path.Base(parsed.Path))
|
|
if !ok {
|
|
return raw, nil
|
|
}
|
|
return jsonString(h.versionURL(name, version))
|
|
}
|
|
|
|
func (h *ChefHandler) apiURL() string {
|
|
return h.proxyURL + chefMountPath + chefAPIPath
|
|
}
|
|
|
|
func (h *ChefHandler) cookbookURL(name string) string {
|
|
return h.apiURL() + "/cookbooks/" + url.PathEscape(name)
|
|
}
|
|
|
|
func (h *ChefHandler) versionURL(name, version string) string {
|
|
return h.cookbookURL(name) + "/versions/" + url.PathEscape(version)
|
|
}
|
|
|
|
func (h *ChefHandler) downloadURL(name, version string) string {
|
|
return h.versionURL(name, version) + "/download"
|
|
}
|
|
|
|
func (h *ChefHandler) upstreamCookbookURL(name string) string {
|
|
return h.upstreamURL + chefAPIPath + "/cookbooks/" + url.PathEscape(name)
|
|
}
|
|
|
|
func (h *ChefHandler) upstreamVersionURL(name, version string) string {
|
|
return h.upstreamCookbookURL(name) + "/versions/" + url.PathEscape(version)
|
|
}
|
|
|
|
// cacheKey prefixes a metadata cache key with the upstream's key.
|
|
func (h *ChefHandler) cacheKey(documentKey string) string {
|
|
return h.upstreamKey + "/" + documentKey
|
|
}
|
|
|
|
// archiveFilename names a cookbook archive in the cache. The filename is part
|
|
// of the artifact's cache identity, so it carries the upstream's key; the
|
|
// .tgz extension is kept for browsing the archive.
|
|
func (h *ChefHandler) archiveFilename(name, version string) string {
|
|
return name + "-" + version + "-" + h.upstreamKey + ".tgz"
|
|
}
|
|
|
|
func chefCookbookFromRequest(r *http.Request) (string, bool) {
|
|
name := r.PathValue("name")
|
|
if !chefCookbookName.MatchString(name) || containsPathTraversal(name) {
|
|
return "", false
|
|
}
|
|
return name, true
|
|
}
|
|
|
|
func chefVersionFromRequest(r *http.Request) (name, version string, ok bool) {
|
|
name, ok = chefCookbookFromRequest(r)
|
|
if !ok {
|
|
return "", "", false
|
|
}
|
|
version, ok = normalizeChefVersion(r.PathValue("version"))
|
|
return name, version, ok
|
|
}
|
|
|
|
// normalizeChefVersion accepts a version in either form Supermarket serves
|
|
// (1.2.3, or 1_2_3 as Berkshelf writes it in URLs) and returns the dotted
|
|
// form.
|
|
func normalizeChefVersion(version string) (string, bool) {
|
|
version = strings.ReplaceAll(version, "_", ".")
|
|
if !chefVersion.MatchString(version) {
|
|
return "", false
|
|
}
|
|
return version, true
|
|
}
|
|
|
|
func jsonString(s string) (json.RawMessage, error) {
|
|
return encodeChefJSON(s)
|
|
}
|
|
|
|
// encodeChefJSON encodes v without escaping HTML characters, so dependency
|
|
// constraints such as ">= 1.0" keep their upstream spelling.
|
|
func encodeChefJSON(v any) (json.RawMessage, error) {
|
|
var buf bytes.Buffer
|
|
enc := json.NewEncoder(&buf)
|
|
enc.SetEscapeHTML(false)
|
|
if err := enc.Encode(v); err != nil {
|
|
return nil, err
|
|
}
|
|
return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil
|
|
}
|