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
33 changes: 10 additions & 23 deletions controllers/dependencyfirewall/composer.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,14 +250,19 @@ func (d *ComposerDependencyProxyController) proxyComposerDist(c shared.Context)
}
}

distURL, releaseTime, err := d.resolveComposerDist(ctx, packageName, version)
registry := configs.getRegistry(composer, composerMetadataRegistry)
distURL, releaseTime, err := d.resolveComposerDist(ctx, packageName, version, registry)
if err != nil {
span.RecordError(err)
slog.Warn("Could not resolve dist url", "proxy", "composer", "package", packageName, "version", version, "error", err)
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("could not resolve %s@%s", packageName, version))
}

if err := checkComposerDistHost(distURL); err != nil {
registryURL, err := url.Parse(registry)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "invalid composer registry")
}
if err := checkDistHost(distURL, append([]string{registryURL.Hostname()}, composerDistHosts...)...); err != nil {
span.RecordError(err)
slog.Warn("Blocked dist url", "proxy", "composer", "package", packageName, "version", version, "url", distURL, "error", err)
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("blocked dist url: %v", err))
Expand Down Expand Up @@ -344,7 +349,7 @@ func (d *ComposerDependencyProxyController) proxyComposerMetadata(c shared.Conte
}

data, headers, statusCode, err := d.fetchFromUpstream(
ctx, composer, composerMetadataRegistry, requestPath, c.Request().Header, nil,
ctx, composer, configs.getRegistry(composer, composerMetadataRegistry), requestPath, c.Request().Header, nil,
)

if err != nil {
Expand Down Expand Up @@ -494,10 +499,10 @@ func filterComposerMetadata(data []byte, proxyBaseURL string, minAge time.Durati
return out, removed, nil
}

func (d *ComposerDependencyProxyController) resolveComposerDist(ctx context.Context, packageName, version string) (string, time.Time, error) {
func (d *ComposerDependencyProxyController) resolveComposerDist(ctx context.Context, packageName, version, registry string) (string, time.Time, error) {
metadataPath := fmt.Sprintf("p2/%s.json", packageName)

data, _, statusCode, err := d.fetchFromUpstream(ctx, composer, composerMetadataRegistry, metadataPath, http.Header{}, nil)
data, _, statusCode, err := d.fetchFromUpstream(ctx, composer, registry, metadataPath, http.Header{}, nil)
if err != nil {
return "", time.Time{}, err
}
Expand Down Expand Up @@ -543,24 +548,6 @@ func (d *ComposerDependencyProxyController) resolveComposerDist(ctx context.Cont
return "", time.Time{}, fmt.Errorf("%s@%s not found upstream", packageName, version)
}

// checkComposerDistHost keeps a tampered or compromised upstream entry from turning this
// proxy into a fetcher for arbitrary hosts.
func checkComposerDistHost(distURL string) error {
parsed, err := url.Parse(distURL)
if err != nil {
return fmt.Errorf("invalid dist url")
}
if parsed.Scheme != "https" {
return fmt.Errorf("dist url is not https")
}
for _, host := range composerDistHosts {
if parsed.Hostname() == host {
return nil
}
}
return fmt.Errorf("dist host %s is not allowed", parsed.Hostname())
}

func composerCacheTTL(requestPath string) time.Duration {
if strings.Contains(requestPath, ".zip") {
return 168 * time.Hour // 7 days
Expand Down
34 changes: 30 additions & 4 deletions controllers/dependencyfirewall/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"net/url"
"os"
"regexp"
"slices"
"strings"
"time"

Expand Down Expand Up @@ -59,8 +60,17 @@ type DependencyProxyCache struct {
}

type DependencyProxyConfigs struct {
Rules []string `json:"rules"`
MinReleaseAge int `json:"minReleaseAge"` // in hours
Rules []string `json:"rules"`
MinReleaseAge int `json:"minReleaseAge"` // in hours
Registries map[string]string `json:"registries"`
}

func (c DependencyProxyConfigs) getRegistry(eco ecosystem, defaultRegistry string) string {
individualRegistry := c.Registries[eco.name()]
if individualRegistry == "" {
return defaultRegistry
}
return individualRegistry
}

type DependencyProxyController struct {
Expand Down Expand Up @@ -293,8 +303,9 @@ func (d *DependencyProxyController) LoadConfigsBySecret(c shared.Context, secret
return configs, fmt.Errorf("unexpected config file json type: %T", configFilesJSON)
}
var raw struct {
Rules string `json:"rules"`
MinReleaseAge int `json:"minReleaseAge"`
Rules string `json:"rules"`
MinReleaseAge int `json:"minReleaseAge"`
Registries map[string]string `json:"registries"`
}
if err := json.Unmarshal([]byte(s), &raw); err != nil {
return configs, fmt.Errorf("failed to unmarshal config file json into configs: %w", err)
Expand All @@ -306,6 +317,7 @@ func (d *DependencyProxyController) LoadConfigsBySecret(c shared.Context, secret
configs.Rules = append(configs.Rules, line)
}
}
configs.Registries = raw.Registries
}

return configs, nil
Expand All @@ -317,6 +329,20 @@ func (d *DependencyProxyController) GetDependencyProxyConfigs(c shared.Context)
return d.LoadConfigsBySecret(c, c.Param("secret"))
}

func checkDistHost(distURL string, allowedHosts ...string) error {
parsed, err := url.Parse(distURL)
if err != nil {
return fmt.Errorf("invalid dist url")
}
if parsed.Scheme != "https" {
return fmt.Errorf("dist url is not https")
}
if !slices.Contains(allowedHosts, parsed.Hostname()) {
return fmt.Errorf("dist host %s is not allowed", parsed.Hostname())
}
return nil
}

// matchPattern matches a packagePurl against a pattern that may contain '*' wildcards.
func matchPattern(pattern, str string) bool {
parts := strings.Split(pattern, "*")
Expand Down
13 changes: 11 additions & 2 deletions controllers/dependencyfirewall/debian.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,16 @@ func (d *DebDependencyProxyController) proxyDebMetadata(c shared.Context) error

slog.Info("Proxy request", "proxy", "deb", "type", "metadata", "method", c.Request().Method, "path", requestPath)

data, headers, statusCode, err := d.fetchFromUpstream(ctx, deb, debRegistry, requestPath, c.Request().Header, nil)
configs, err := d.GetDependencyProxyConfigs(c)
if err != nil {
slog.Error("Error getting dependency proxy configs", "error", err)
if strings.Contains(err.Error(), "invalid dependency proxy secret") {
return echo.NewHTTPError(http.StatusUnauthorized, "dependency proxy secret is required or invalid")
}
return echo.NewHTTPError(http.StatusInternalServerError, "failed to load dependency proxy configuration")
}

data, headers, statusCode, err := d.fetchFromUpstream(ctx, deb, configs.getRegistry(deb, debRegistry), requestPath, c.Request().Header, nil)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
Expand Down Expand Up @@ -263,7 +272,7 @@ func (d *DebDependencyProxyController) proxyDebPackage(c shared.Context) error {

span.SetAttributes(attribute.Bool("proxy.cache_hit", false))

data, headers, statusCode, err := d.fetchFromUpstream(ctx, deb, debRegistry, requestPath, c.Request().Header, nil)
data, headers, statusCode, err := d.fetchFromUpstream(ctx, deb, configs.getRegistry(deb, debRegistry), requestPath, c.Request().Header, nil)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
Expand Down
16 changes: 10 additions & 6 deletions controllers/dependencyfirewall/golang.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ func (d *GoDependencyProxyController) proxyGoExplicitVersion(c shared.Context, c

span.SetAttributes(attribute.Bool("proxy.cache_hit", false))

data, headers, statusCode, err := d.fetchFromUpstream(ctx, eco, goProxyURL, requestPath, c.Request().Header, nil)
data, headers, statusCode, err := d.fetchFromUpstream(ctx, eco, configs.getRegistry(golang, goProxyURL), requestPath, c.Request().Header, nil)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
Expand All @@ -230,7 +230,7 @@ func (d *GoDependencyProxyController) proxyGoExplicitVersion(c shared.Context, c
releaseTimeErr = fmt.Errorf("no release time in .info response")
}
} else {
releaseTime, releaseTimeErr = d.fetchGoReleaseTime(ctx, moduleName, version)
releaseTime, releaseTimeErr = d.fetchGoReleaseTime(ctx, moduleName, version, configs.getRegistry(golang, goProxyURL))
}
if releaseTimeErr != nil {
slog.Warn("Could not determine release time", "proxy", "go", "module", moduleName, "version", version, "error", releaseTimeErr)
Expand Down Expand Up @@ -273,8 +273,10 @@ func (d *GoDependencyProxyController) proxyGoLatest(c shared.Context, ctx contex
return d.blockMaliciousPackage(c, eco, requestPath, reason, status)
}

registry := configs.getRegistry(golang, goProxyURL)

// Fetch from upstream — we need the response to resolve the version before we can check rules.
data, headers, statusCode, err := d.fetchFromUpstream(ctx, eco, goProxyURL, requestPath, c.Request().Header, nil)
data, headers, statusCode, err := d.fetchFromUpstream(ctx, eco, registry, requestPath, c.Request().Header, nil)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
Expand All @@ -292,7 +294,9 @@ func (d *GoDependencyProxyController) proxyGoLatest(c shared.Context, ctx contex
if strings.HasSuffix(requestPath, "/@v/list") && (configs.MinReleaseAge > 0 || len(configs.Rules) > 0) {
filtered, removed := filterGoVersionList(data,
time.Duration(configs.MinReleaseAge)*time.Hour,
func(version string) (time.Time, error) { return d.fetchGoReleaseTime(ctx, packageName, version) },
func(version string) (time.Time, error) {
return d.fetchGoReleaseTime(ctx, packageName, version, registry)
},
func(version string) bool {
blocked, _ := matchRules(eco.packageIdentifier(packageName, version), configs.Rules)
return !blocked
Expand Down Expand Up @@ -360,14 +364,14 @@ func (d *GoDependencyProxyController) proxyGoSumDB(c shared.Context, ctx context
// fetchGoReleaseTime returns the release time of module@version from the version's .info
// file, preferring the cached copy. A fetched .info is cached as well, so the explicit
// .info request and the version list filtering share it.
func (d *GoDependencyProxyController) fetchGoReleaseTime(ctx context.Context, moduleName, version string) (time.Time, error) {
func (d *GoDependencyProxyController) fetchGoReleaseTime(ctx context.Context, moduleName, version, registry string) (time.Time, error) {
infoPath := moduleName + "/@v/" + version + ".info"
cacheKey := "go/" + infoPath
if entry, ok := d.cache.Get(cacheKey); ok && !entry.releaseTime.IsZero() {
return entry.releaseTime, nil
}

data, _, statusCode, err := d.fetchFromUpstream(ctx, golang, goProxyURL, infoPath, http.Header{}, nil)
data, _, statusCode, err := d.fetchFromUpstream(ctx, golang, registry, infoPath, http.Header{}, nil)
if err != nil {
return time.Time{}, err
}
Expand Down
14 changes: 9 additions & 5 deletions controllers/dependencyfirewall/maven.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ func (d *MavenDependencyProxyController) proxyMavenPackage(c shared.Context) err

span.SetAttributes(attribute.Bool("proxy.cache_hit", false))

data, headers, statusCode, err := d.fetchFromUpstream(ctx, maven, mavenRegistry, requestPath, c.Request().Header, nil)
data, headers, statusCode, err := d.fetchFromUpstream(ctx, maven, configs.getRegistry(maven, mavenRegistry), requestPath, c.Request().Header, nil)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
Expand Down Expand Up @@ -334,7 +334,9 @@ func (d *MavenDependencyProxyController) proxyMavenMetadata(c shared.Context) er

span.SetAttributes(attribute.Bool("proxy.cache_hit", false))

data, headers, statusCode, err := d.fetchFromUpstream(ctx, maven, mavenRegistry, requestPath, c.Request().Header, nil)
registry := configs.getRegistry(maven, mavenRegistry)

data, headers, statusCode, err := d.fetchFromUpstream(ctx, maven, registry, requestPath, c.Request().Header, nil)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
Expand All @@ -356,7 +358,9 @@ func (d *MavenDependencyProxyController) proxyMavenMetadata(c shared.Context) er
if version == "" && (configs.MinReleaseAge > 0 || len(configs.Rules) > 0) {
filtered, removed := filterMavenMetadata(data,
time.Duration(configs.MinReleaseAge)*time.Hour,
func(v string) (time.Time, error) { return d.fetchMavenReleaseTime(ctx, packageName, v) },
func(v string) (time.Time, error) {
return d.fetchMavenReleaseTime(ctx, packageName, v, registry)
},
func(v string) bool {
blocked, _ := matchRules(maven.packageIdentifier(packageName, v), configs.Rules)
return !blocked
Expand All @@ -377,7 +381,7 @@ func (d *MavenDependencyProxyController) proxyMavenMetadata(c shared.Context) er
// the version's POM is the only source. The POM is used rather than the JAR because it is
// small and always exists, even for pom-packaging artifacts. The cache key matches the one
// proxyMavenPackage uses, so a POM that was proxied normally is reused here.
func (d *MavenDependencyProxyController) fetchMavenReleaseTime(ctx context.Context, packageName, version string) (time.Time, error) {
func (d *MavenDependencyProxyController) fetchMavenReleaseTime(ctx context.Context, packageName, version, registry string) (time.Time, error) {
groupID, artifactID, ok := strings.Cut(packageName, "/")
if !ok {
return time.Time{}, fmt.Errorf("invalid maven coordinates %q", packageName)
Expand All @@ -390,7 +394,7 @@ func (d *MavenDependencyProxyController) fetchMavenReleaseTime(ctx context.Conte
return entry.releaseTime, nil
}

data, headers, statusCode, err := d.fetchFromUpstream(ctx, maven, mavenRegistry, pomPath, http.Header{}, nil)
data, headers, statusCode, err := d.fetchFromUpstream(ctx, maven, registry, pomPath, http.Header{}, nil)
if err != nil {
return time.Time{}, err
}
Expand Down
29 changes: 19 additions & 10 deletions controllers/dependencyfirewall/npm.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ func (d *NPMDependencyProxyController) ProxyNPMTarball(c shared.Context) error {
// The tarball itself carries no publish date - it lives in the package metadata.
// Always resolve it, even without MinReleaseAge: the cache is shared across proxy
// secrets, so an entry stored for one config must be checkable under another.
releaseTime, err := d.fetchNPMReleaseTime(ctx, packageName, version)
releaseTime, err := d.fetchNPMReleaseTime(ctx, packageName, version, configs.getRegistry(npm, npmRegistry))
if err != nil {
slog.Warn("Could not determine release time", "proxy", "npm", "package", packageName, "version", version, "error", err)
if configs.MinReleaseAge > 0 {
Expand All @@ -200,7 +200,7 @@ func (d *NPMDependencyProxyController) ProxyNPMTarball(c shared.Context) error {
}
}

data, headers, statusCode, err := d.fetchFromUpstream(ctx, npm, npmRegistry, requestPath, c.Request().Header, nil)
data, headers, statusCode, err := d.fetchFromUpstream(ctx, npm, configs.getRegistry(npm, npmRegistry), requestPath, c.Request().Header, nil)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
Expand Down Expand Up @@ -240,7 +240,7 @@ var npmMetadata = utils.NewTTLCache[string](5*time.Minute, 256*1024*1024, func(e

// fetchPackageMetadata returns the full (non-abbreviated) npm package document.
// Only successful responses are cached.
func (d *NPMDependencyProxyController) fetchPackageMetadata(ctx context.Context, packageName string) ([]byte, http.Header, int, error) {
func (d *NPMDependencyProxyController) fetchPackageMetadata(ctx context.Context, packageName, registry string) ([]byte, http.Header, int, error) {
cacheKey := "npm/metadata/" + packageName
if entry, ok := npmMetadata.Get(cacheKey); ok {
slog.Debug("Cache hit for metadata", "proxy", "npm", "package", packageName)
Expand All @@ -249,7 +249,7 @@ func (d *NPMDependencyProxyController) fetchPackageMetadata(ctx context.Context,
return entry.data, headers, http.StatusOK, nil
}

data, headers, status, err := d.fetchFromUpstream(ctx, npm, npmRegistry, "/"+packageName, nil, nil)
data, headers, status, err := d.fetchFromUpstream(ctx, npm, registry, "/"+packageName, nil, nil)
if err != nil {
return nil, nil, status, fmt.Errorf("failed to fetch metadata from upstream: %w", err)
}
Expand All @@ -260,8 +260,8 @@ func (d *NPMDependencyProxyController) fetchPackageMetadata(ctx context.Context,
}

// fetchNPMReleaseTime returns the publish time of packageName@version from the package metadata.
func (d *NPMDependencyProxyController) fetchNPMReleaseTime(ctx context.Context, packageName, version string) (time.Time, error) {
metadata, _, status, err := d.fetchPackageMetadata(ctx, packageName)
func (d *NPMDependencyProxyController) fetchNPMReleaseTime(ctx context.Context, packageName, version, registry string) (time.Time, error) {
metadata, _, status, err := d.fetchPackageMetadata(ctx, packageName, registry)
if err != nil {
return time.Time{}, err
}
Expand Down Expand Up @@ -336,7 +336,7 @@ func (d *NPMDependencyProxyController) ProxyNPMMetadata(c shared.Context) error
return d.blockMaliciousPackage(c, npm, requestPath, reason, status)
}

data, headers, statusCode, err := d.fetchPackageMetadata(ctx, packageName)
data, headers, statusCode, err := d.fetchPackageMetadata(ctx, packageName, configs.getRegistry(npm, npmRegistry))
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
Expand Down Expand Up @@ -396,6 +396,15 @@ func (d *NPMDependencyProxyController) ProxyNPMRegistryAPI(c shared.Context) err
requestPath := npm.trimPrefix(c.Request().URL.Path)
method := c.Request().Method

configs, err := d.GetDependencyProxyConfigs(c)
if err != nil {
slog.Error("Error getting dependency proxy configs", "error", err)
if strings.Contains(err.Error(), "invalid dependency proxy secret") {
return echo.NewHTTPError(http.StatusUnauthorized, "dependency proxy secret is required or invalid")
}
return echo.NewHTTPError(http.StatusInternalServerError, "failed to load dependency proxy configuration")
}

ctx, span := depProxyTracer.Start(c.Request().Context(), "dependency-proxy.npm-registry-api",
trace.WithAttributes(
attribute.String("proxy.ecosystem", "npm"),
Expand Down Expand Up @@ -423,7 +432,7 @@ func (d *NPMDependencyProxyController) ProxyNPMRegistryAPI(c shared.Context) err

slog.Info("Proxy request", "proxy", "npm", "type", "registry-api", "method", method, "path", requestPath, "bodySize", len(body))

data, headers, statusCode, err := d.fetchNPMRegistryAPIFromUpstream(ctx, method, requestPath, c.Request().URL.RawQuery, c.Request().Header, body)
data, headers, statusCode, err := d.fetchNPMRegistryAPIFromUpstream(ctx, method, requestPath, c.Request().URL.RawQuery, c.Request().Header, body, configs.getRegistry(npm, npmRegistry))
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
Expand All @@ -436,9 +445,9 @@ func (d *NPMDependencyProxyController) ProxyNPMRegistryAPI(c shared.Context) err

// fetchNPMRegistryAPIFromUpstream forwards a registry API request including the
// headers npm relies on (body encoding for audits, Accept for content negotiation).
func (d *NPMDependencyProxyController) fetchNPMRegistryAPIFromUpstream(ctx context.Context, method, requestPath, rawQuery string, headers http.Header, body []byte) ([]byte, http.Header, int, error) {
func (d *NPMDependencyProxyController) fetchNPMRegistryAPIFromUpstream(ctx context.Context, method, requestPath, rawQuery string, headers http.Header, body []byte, registry string) ([]byte, http.Header, int, error) {
requestPath = strings.TrimRight(requestPath, "/")
upstreamURL, err := url.JoinPath(npmRegistry, requestPath)
upstreamURL, err := url.JoinPath(registry, requestPath)
if err != nil {
return nil, nil, 0, fmt.Errorf("failed to join URL: %w", err)
}
Expand Down
Loading