mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-11-09 23:59:16 -05:00
parseComposerUpstreamIndex now returns an error for a packages.json that is not a JSON object or has the advisories and filter fields in the wrong shape, and fetchUpstreamIndex passes it to the metadata cache as validate. A malformed 200 is no longer stored: with metadata caching the stored copy is served as when upstream fails, and without it the error keeps the copy in memory.
351 lines
12 KiB
Go
351 lines
12 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/sync/singleflight"
|
|
)
|
|
|
|
const (
|
|
composerAdvisoriesPath = "/api/security-advisories/"
|
|
composerFilterSummaryPath = "/lists/summary.json"
|
|
composerPackagesParam = "packages"
|
|
|
|
// composerAdvisoriesMaxBody caps an advisories query. Composer sends at
|
|
// most 500 package names per request.
|
|
composerAdvisoriesMaxBody = 1 << 20
|
|
)
|
|
|
|
// composerUpstreamIndex holds the parts of the default repository's
|
|
// packages.json that the proxy advertises in its own: the security
|
|
// advisories API and the filter lists (Packagist's malware list). Composer
|
|
// reads advisories and filter entries for each package from its p2 metadata,
|
|
// which the proxy passes on unchanged, and asks these URLs for the rest.
|
|
type composerUpstreamIndex struct {
|
|
advisoriesURL string
|
|
advisoriesMetadata bool
|
|
filterLists json.RawMessage
|
|
filterSummaryURL string
|
|
}
|
|
|
|
// composerIndexFallbackTTL is how long the upstream index is kept when the
|
|
// metadata TTL is 0.
|
|
const composerIndexFallbackTTL = time.Hour
|
|
|
|
// composerIndexCache keeps the default repository's parsed packages.json in
|
|
// memory, whether or not metadata caching is on, so that serving the proxy's
|
|
// own packages.json does not wait on the upstream. A failed fetch is
|
|
// remembered for the same interval: the last good copy, or none, is used
|
|
// until then instead of retrying on every request. The zero value is ready
|
|
// to use.
|
|
type composerIndexCache struct {
|
|
mu sync.Mutex
|
|
index composerUpstreamIndex
|
|
ok bool
|
|
fetchedAt time.Time
|
|
group singleflight.Group
|
|
now func() time.Time // for tests; nil means time.Now
|
|
}
|
|
|
|
func (c *composerIndexCache) clock() time.Time {
|
|
if c.now != nil {
|
|
return c.now()
|
|
}
|
|
return time.Now()
|
|
}
|
|
|
|
// upstreamIndex returns the default repository's packages.json fields. ok is
|
|
// false when the repository has not been reached since the proxy started; a
|
|
// document without the fields yields an empty index.
|
|
func (h *ComposerHandler) upstreamIndex(ctx context.Context) (composerUpstreamIndex, bool) {
|
|
c := &h.indexCache
|
|
ttl := h.proxy.MetadataTTL
|
|
if ttl <= 0 {
|
|
ttl = composerIndexFallbackTTL
|
|
}
|
|
c.mu.Lock()
|
|
if !c.fetchedAt.IsZero() && c.clock().Sub(c.fetchedAt) < ttl {
|
|
index, ok := c.index, c.ok
|
|
c.mu.Unlock()
|
|
return index, ok
|
|
}
|
|
c.mu.Unlock()
|
|
|
|
// One fetch serves every waiting request. It is detached from the
|
|
// request that started it, so a client hanging up neither fails the
|
|
// others nor gets remembered as an upstream failure.
|
|
_, _, _ = c.group.Do("index", func() (any, error) {
|
|
index, err := h.fetchUpstreamIndex(context.WithoutCancel(ctx))
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if err == nil {
|
|
c.index, c.ok = index, true
|
|
}
|
|
c.fetchedAt = c.clock()
|
|
return nil, nil
|
|
})
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.index, c.ok
|
|
}
|
|
|
|
// fetchUpstreamIndex fetches and parses the default repository's
|
|
// packages.json. A document that doesn't parse is rejected before it is
|
|
// cached, so a broken response never replaces a good copy: with metadata
|
|
// caching the stored copy is used instead, and without it the error keeps
|
|
// the copy in memory.
|
|
func (h *ComposerHandler) fetchUpstreamIndex(ctx context.Context) (composerUpstreamIndex, error) {
|
|
indexURL := h.repoURL + "/packages.json"
|
|
cacheKey := hashedCacheKey(h.repoURL, "/packages.json")
|
|
validate := func(body []byte) error {
|
|
_, err := parseComposerUpstreamIndex(indexURL, body)
|
|
return err
|
|
}
|
|
res := h.proxy.fetchMetadataDocument(ctx, "composer", cacheKey, indexURL, contentTypeJSON, validate)
|
|
if res.err != nil {
|
|
h.proxy.Logger.Warn("failed to fetch upstream composer packages.json", "url", indexURL, "error", res.err)
|
|
return composerUpstreamIndex{}, res.err
|
|
}
|
|
return parseComposerUpstreamIndex(indexURL, res.body)
|
|
}
|
|
|
|
// errComposerIndexNotObject is returned for a packages.json that is valid
|
|
// JSON but not an object, such as null.
|
|
var errComposerIndexNotObject = errors.New("composer packages.json is not a JSON object")
|
|
|
|
// parseComposerUpstreamIndex reads the advisories and filter fields of a
|
|
// packages.json. A document without them yields an empty index; one that is
|
|
// not a JSON object, or has them in the wrong shape, is an error.
|
|
func parseComposerUpstreamIndex(indexURL string, body []byte) (composerUpstreamIndex, error) {
|
|
var doc struct {
|
|
SecurityAdvisories struct {
|
|
Metadata bool `json:"metadata"`
|
|
APIURL string `json:"api-url"`
|
|
} `json:"security-advisories"`
|
|
Filter struct {
|
|
Metadata bool `json:"metadata"`
|
|
Lists json.RawMessage `json:"lists"`
|
|
SummaryURL string `json:"summary-url"`
|
|
} `json:"filter"`
|
|
}
|
|
var index composerUpstreamIndex
|
|
if trimmed := bytes.TrimSpace(body); len(trimmed) == 0 || trimmed[0] != '{' {
|
|
return index, errComposerIndexNotObject
|
|
}
|
|
if err := json.Unmarshal(body, &doc); err != nil {
|
|
return index, fmt.Errorf("parsing composer packages.json: %w", err)
|
|
}
|
|
// Composer refuses advisories configured without an api-url unless the
|
|
// repository lists its packages, which the proxy does not.
|
|
if apiURL, ok := resolveComposerURL(indexURL, doc.SecurityAdvisories.APIURL); ok {
|
|
index.advisoriesURL = apiURL
|
|
index.advisoriesMetadata = doc.SecurityAdvisories.Metadata
|
|
}
|
|
// Composer only applies filter lists a repository marks with metadata.
|
|
// A filter api-url is not passed on: without it Composer reads the
|
|
// entries from package metadata, narrowed by the summary.
|
|
if doc.Filter.Metadata && len(doc.Filter.Lists) > 0 && doc.Filter.Lists[0] == '{' {
|
|
index.filterLists = doc.Filter.Lists
|
|
index.filterSummaryURL, _ = resolveComposerURL(indexURL, doc.Filter.SummaryURL)
|
|
}
|
|
return index, nil
|
|
}
|
|
|
|
// resolveComposerURL resolves a URL from packages.json against the document's
|
|
// own, as Composer does, and accepts only http and https.
|
|
func resolveComposerURL(base, ref string) (string, bool) {
|
|
if ref == "" {
|
|
return "", false
|
|
}
|
|
b, err := url.Parse(base)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
u, err := b.Parse(ref)
|
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
|
return "", false
|
|
}
|
|
return u.String(), true
|
|
}
|
|
|
|
// advertise adds the advisories and filter entries to the proxy's
|
|
// packages.json, pointing at the proxy.
|
|
func (index composerUpstreamIndex) advertise(packages map[string]any, proxyURL string) {
|
|
if index.advisoriesURL != "" {
|
|
packages["security-advisories"] = map[string]any{
|
|
"metadata": index.advisoriesMetadata,
|
|
"api-url": proxyURL + "/composer" + composerAdvisoriesPath,
|
|
}
|
|
}
|
|
if index.filterLists != nil {
|
|
filter := map[string]any{"metadata": true, "lists": index.filterLists}
|
|
if index.filterSummaryURL != "" {
|
|
filter["summary-url"] = proxyURL + "/composer" + composerFilterSummaryPath
|
|
}
|
|
packages["filter"] = filter
|
|
}
|
|
}
|
|
|
|
// handleSecurityAdvisories relays a security advisories query to the URL the
|
|
// default repository advertises. Composer sends the package names as
|
|
// packages[] in a POST form; a GET query is accepted too. Names served by a
|
|
// package route or a hosted-only vendor are removed first, so private package
|
|
// names are never sent to the public API.
|
|
//
|
|
// Responses are not cached: each query names a different set of packages.
|
|
func (h *ComposerHandler) handleSecurityAdvisories(w http.ResponseWriter, r *http.Request) {
|
|
query, err := h.advisoriesQuery(w, r)
|
|
if err != nil {
|
|
var maxBytesErr *http.MaxBytesError
|
|
if errors.As(err, &maxBytesErr) {
|
|
JSONError(w, http.StatusRequestEntityTooLarge, "advisories request too large")
|
|
return
|
|
}
|
|
JSONError(w, http.StatusBadRequest, "could not read advisories request")
|
|
return
|
|
}
|
|
|
|
asked := len(query[composerPackagesParam+"[]"])
|
|
public := h.publicPackageNames(query[composerPackagesParam+"[]"])
|
|
if asked > 0 && len(public) == 0 {
|
|
w.Header().Set(headerContentType, contentTypeJSON)
|
|
_, _ = io.WriteString(w, `{"advisories":{}}`)
|
|
return
|
|
}
|
|
if asked > 0 {
|
|
query[composerPackagesParam+"[]"] = public
|
|
}
|
|
|
|
index, ok := h.upstreamIndex(r.Context())
|
|
if !ok {
|
|
JSONError(w, http.StatusBadGateway, "upstream repository unavailable")
|
|
return
|
|
}
|
|
if index.advisoriesURL == "" {
|
|
JSONError(w, http.StatusNotFound, "upstream repository has no security advisories API")
|
|
return
|
|
}
|
|
|
|
req, err := h.advisoriesRequest(r, index.advisoriesURL, query)
|
|
if err != nil {
|
|
JSONError(w, http.StatusInternalServerError, "failed to create request")
|
|
return
|
|
}
|
|
h.proxy.Logger.Debug("composer advisories request", "packages", len(public), "withheld", asked-len(public))
|
|
|
|
resp, err := h.proxy.HTTPClient.Do(req)
|
|
if err != nil {
|
|
if writeUpstreamHeld(w, err) {
|
|
return
|
|
}
|
|
h.proxy.Logger.Error("composer advisories request failed", "error", err)
|
|
JSONError(w, http.StatusBadGateway, "failed to reach upstream repository")
|
|
return
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
h.proxy.relayResponse(w, r, resp, nil)
|
|
}
|
|
|
|
// advisoriesQuery reads the query of an advisories request, from the form
|
|
// body of a POST or the URL of a GET. Package names come back under
|
|
// "packages[]" whichever spelling the client used: PHP's http_build_query,
|
|
// which Composer uses, numbers them as packages[0], packages[1], ...
|
|
func (h *ComposerHandler) advisoriesQuery(w http.ResponseWriter, r *http.Request) (url.Values, error) {
|
|
raw := r.URL.RawQuery
|
|
if r.Method == http.MethodPost {
|
|
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, composerAdvisoriesMaxBody))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
raw = string(body)
|
|
}
|
|
values, err := url.ParseQuery(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
query := url.Values{}
|
|
for key, vals := range values {
|
|
if key == composerPackagesParam || strings.HasPrefix(key, composerPackagesParam+"[") {
|
|
key = composerPackagesParam + "[]"
|
|
}
|
|
query[key] = append(query[key], vals...)
|
|
}
|
|
return query, nil
|
|
}
|
|
|
|
// publicPackageNames drops the names the default repository does not serve.
|
|
func (h *ComposerHandler) publicPackageNames(names []string) []string {
|
|
public := make([]string, 0, len(names))
|
|
for _, name := range names {
|
|
if _, routed := h.routes.match(name); routed || h.proxy.hostedOnly("composer", name) {
|
|
continue
|
|
}
|
|
public = append(public, name)
|
|
}
|
|
return public
|
|
}
|
|
|
|
// advisoriesRequest builds the upstream request, sending the query the way
|
|
// the client did.
|
|
func (h *ComposerHandler) advisoriesRequest(r *http.Request, apiURL string, query url.Values) (*http.Request, error) {
|
|
u, err := url.Parse(apiURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var body io.Reader
|
|
if r.Method == http.MethodPost {
|
|
body = strings.NewReader(query.Encode())
|
|
} else {
|
|
u.RawQuery = joinQuery(u.RawQuery, query.Encode())
|
|
}
|
|
req, err := http.NewRequestWithContext(r.Context(), r.Method, u.String(), body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if r.Method == http.MethodPost {
|
|
req.Header.Set(headerContentType, "application/x-www-form-urlencoded")
|
|
}
|
|
for _, header := range []string{headerAccept, headerAcceptEncoding} {
|
|
if v := r.Header.Get(header); v != "" {
|
|
req.Header.Set(header, v)
|
|
}
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
func joinQuery(a, b string) string {
|
|
if a == "" || b == "" {
|
|
return a + b
|
|
}
|
|
return a + "&" + b
|
|
}
|
|
|
|
// handleFilterSummary relays the filter list summary the default repository
|
|
// advertises. It lists every package on a filter list, so the request names
|
|
// no packages. It is not cached, so a newly listed package is blocked as soon
|
|
// as the upstream lists it; Composer revalidates its own copy with
|
|
// If-Modified-Since, which is passed on.
|
|
func (h *ComposerHandler) handleFilterSummary(w http.ResponseWriter, r *http.Request) {
|
|
index, ok := h.upstreamIndex(r.Context())
|
|
if !ok {
|
|
JSONError(w, http.StatusBadGateway, "upstream repository unavailable")
|
|
return
|
|
}
|
|
if index.filterSummaryURL == "" {
|
|
JSONError(w, http.StatusNotFound, "upstream repository has no filter list summary")
|
|
return
|
|
}
|
|
h.proxy.ProxyUpstream(w, r, index.filterSummaryURL,
|
|
[]string{headerAccept, headerAcceptEncoding, "If-Modified-Since", "If-None-Match"})
|
|
}
|