diff --git a/internal/handlers/egress_allowlist_defaults.yaml b/internal/handlers/egress_allowlist_defaults.yaml index 896d368..fecfa4f 100644 --- a/internal/handlers/egress_allowlist_defaults.yaml +++ b/internal/handlers/egress_allowlist_defaults.yaml @@ -26,7 +26,9 @@ # only ever allowed as an EXACT apex host (never a glob or leading-dot form) and # only where a public ecosystem's downloads redirect there; see its accepted-risk # note in go_modules. Virtual-hosted ".storage.googleapis.com" subdomains -# stay blocked. The other glob over unreserved shared storage retained here is +# stay blocked as a class, and are only ever added one exact, already-registered +# bucket at a time (see maven-central in maven), never as a glob or leading-dot +# form. The other glob over unreserved shared storage retained here is # the NuGet CDN (see its entries), a known, accepted exposure documented at its # call site, not a pattern to copy. @@ -147,6 +149,7 @@ shared_registry_domains: # job, via the credential-derived dynamic hosts instead. # Public mirrors (continued). - mirrors.cloud.tencent.com + - mirrors.huaweicloud.com # ecosystem_default_domains lists the public registry and CDN hosts each # Dependabot ecosystem needs. The map is kept keyed by ecosystem for provenance @@ -191,6 +194,16 @@ ecosystem_default_domains: # GitHub Packages npm content host; npm.pkg.github.com 302-redirects here. # Exact only; listed in api.github.com/meta domains.packages. - npmregistryv2prod.blob.core.windows.net + # Node.js runtime downloads. pnpm fetches these when a lockfile pins a Node + # runtime (devEngines); unofficial-builds serves the musl and other + # non-official platform builds. Both are exact entries, so neither opens the + # nodejs.org namespace. + - nodejs.org + - unofficial-builds.nodejs.org + # Continuous-release preview packages, published per commit/PR. Shared host + # with the namespace in the path; unlike Cloudsmith above it exposes no + # per-tenant request logs, so it is listed rather than credential-derived. + - pkg.pr.new bun: *npm_registries pip: &python_registries - pypi.org @@ -223,6 +236,16 @@ ecosystem_default_domains: # GitHub Packages Maven content host; maven.pkg.github.com 302-redirects here. # Exact only; listed in api.github.com/meta domains.packages. - mavenregistryv2prod.blob.core.windows.net + # Maven Central's Google-hosted mirror. Exact virtual-hosted buckets only; + # the "maven-central" bucket is already owned, so it cannot be claimed. + - maven-central.storage.googleapis.com + - maven-central.storage-download.googleapis.com + # Public vendor/community Maven repositories, served anonymously. + - repo.osgeo.org + - androidx.dev + # packages.atlassian.com 301-redirects to maven.artifacts.atlassian.com. + - packages.atlassian.com + - maven.artifacts.atlassian.com gradle: *jvm_registries sbt: - repo1.maven.org @@ -246,7 +269,6 @@ ecosystem_default_domains: - proxy.golang.org - sum.golang.org - .googlesource.com - - nodejs.org # Public Go module vanity-import hosts (each serves a fixed "go-import" # host, not a user-creatable name). - golang.org @@ -284,7 +306,7 @@ ecosystem_default_domains: # reaches every bucket, not just the Go/Dart ones — an accepted residual # exfiltration risk taken to keep public Go and Dart restores working. The # apex entry does NOT match virtual-hosted ".storage.googleapis.com" - # subdomains, which stay blocked. + # subdomains, which stay blocked unless listed exactly (see maven-central). - storage.googleapis.com docker: &docker_registries - registry-1.docker.io @@ -301,11 +323,25 @@ ecosystem_default_domains: - lscr.io - .gcr.io - .pkg.dev + # Vendor-operated public OCI registries that allow anonymous pulls. + # docker.getcollate.io fronts Docker Hub via Scarf, so its token service is + # auth.docker.io above and its blobs land on the Docker Hub CDN hosts above. + - docker.getcollate.io + # Elastic's registry, its anonymous token service, and the R2 bucket its + # blob downloads 307-redirect to. The bucket host embeds Elastic's own + # Cloudflare account hash, so it is exact only: a glob over + # *.r2.cloudflarestorage.com would match every Cloudflare tenant. + - docker.elastic.co + - docker-auth.elastic.co + - docker-registry-production.d24a988e385e0074d717b6bdaea58f0d.r2.cloudflarestorage.com docker_compose: *docker_registries nuget: - api.nuget.org - www.nuget.org - globalcdn.nuget.org + # Legacy NuGet endpoint (Microsoft-owned, Akamai-fronted) still requested by + # older clients. Every path 404s; allowed so restores see 404, not a block. + - data.nuget.org # Exact, provider-owned storage backend (the account name is globally unique # and already taken by NuGet, so it cannot be spoofed). - nugetregistryv2prod.blob.core.windows.net @@ -368,6 +404,8 @@ ecosystem_default_domains: - pkg.julialang.org - us-east.pkg.julialang.org - us-west.pkg.julialang.org + # Official Julia release/artifact storage. + - julialang-s3.julialang.org rust_toolchain: - static.rust-lang.org conda: diff --git a/internal/handlers/egress_allowlist_test.go b/internal/handlers/egress_allowlist_test.go index 0f16861..c5c3190 100644 --- a/internal/handlers/egress_allowlist_test.go +++ b/internal/handlers/egress_allowlist_test.go @@ -240,6 +240,123 @@ func TestEgressAllowlist_GitHubPackagesContentHostsAllowed(t *testing.T) { } } +func TestEgressAllowlist_PublicVendorOCIRegistriesAllowed(t *testing.T) { + // Vendor-operated public OCI registries that serve anonymous pulls. Each + // needs its registry host, its token service, and whatever host its blob + // downloads redirect to; allowing only the registry fixes tag discovery but + // still fails the pull. + h := newEgressHandler(false, true, "docker_compose") + + for _, allowed := range []string{ + "https://docker.getcollate.io/v2/openmetadata/server/tags/list", + "https://auth.docker.io/token?service=registry.docker.io", // getcollate's token service + "https://docker.elastic.co/v2/elasticsearch/elasticsearch/tags/list", + "https://docker-auth.elastic.co/auth?service=token-service", + "https://docker-registry-production.d24a988e385e0074d717b6bdaea58f0d.r2.cloudflarestorage.com/docker/registry/v2/blobs/sha256/x/data", + } { + assert.Nil(t, egressResult(t, h, allowed), "public vendor OCI host allowed: "+allowed) + } + + for _, blocked := range []string{ + // Child hosts pin the exact-host semantics. + "https://evil.docker.elastic.co/v2/", + "https://evil.docker.getcollate.io/v2/", + "https://evil.docker-registry-production.d24a988e385e0074d717b6bdaea58f0d.r2.cloudflarestorage.com/loot", + // R2 is multi-tenant: only Elastic's own account hash is allowed. + "https://loot.deadbeefdeadbeefdeadbeefdeadbeef.r2.cloudflarestorage.com/loot", + "https://attacker.r2.cloudflarestorage.com/loot", + // getcollate fronts Docker Hub through Scarf, which is multi-tenant. + "https://attacker.docker.scarf.sh/v2/", + "https://docker.scarf.sh/v2/", + } { + resp := egressResult(t, h, blocked) + if assert.NotNil(t, resp, "vendor OCI entries must not widen to: "+blocked) { + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + } + } +} + +func TestEgressAllowlist_NodeRuntimeDownloadsAllowed(t *testing.T) { + // pnpm re-resolves a lockfile-pinned Node runtime (devEngines) by fetching + // checksums from the Node.js project's unofficial-builds host. Since pnpm + // 12.6 a 403 there is fatal, so blocking it fails every dependency. + h := newEgressHandler(false, true, "npm_and_yarn") + + for _, allowed := range []string{ + "https://unofficial-builds.nodejs.org/download/release/v24.20.0/SHASUMS256.txt", + "https://unofficial-builds.nodejs.org/download/release/v24.20.0/node-v24.20.0-linux-x64-musl.tar.xz", + "https://nodejs.org/dist/v24.20.0/SHASUMS256.txt", + } { + assert.Nil(t, egressResult(t, h, allowed), "node runtime download allowed: "+allowed) + } + + for _, blocked := range []string{ + // Both entries are exact; neither opens the nodejs.org namespace. + "https://evil.unofficial-builds.nodejs.org/payload", + "https://attacker.nodejs.org/payload", + } { + resp := egressResult(t, h, blocked) + if assert.NotNil(t, resp, "node entries must not widen to: "+blocked) { + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + } + } + + // These entries live under npm_and_yarn, but every job gets the union of all + // ecosystem defaults, so a Go job still reaches them. + goJob := newEgressHandler(false, true, "go_modules") + assert.Nil(t, egressResult(t, goJob, "https://nodejs.org/dist/index.json"), + "nodejs.org must stay reachable from a go_modules job") +} + +// TestEgressAllowlist_PublicEcosystemMirrorsAllowed covers the public hosts +// recorded as blocked during the 25% enforce rollout. Each is anonymous, +// provider-controlled package infrastructure with no attacker-choosable label. +func TestEgressAllowlist_PublicEcosystemMirrorsAllowed(t *testing.T) { + h := newEgressHandler(false, true, "") + + for _, allowed := range []string{ + // Legacy NuGet host: Microsoft-owned, every path 404s. Allowed so the + // client sees a 404 it handles rather than a proxy block it does not. + "https://data.nuget.org/packages/", + // Maven Central's Google-hosted mirror. + "https://maven-central.storage.googleapis.com/maven2/org/slf4j/slf4j-api/maven-metadata.xml", + "https://maven-central.storage-download.googleapis.com/maven2/org/slf4j/slf4j-api/maven-metadata.xml", + "https://repo.osgeo.org/repository/release/org/geotools/gt-main/30.0/gt-main-30.0.pom", + "https://androidx.dev/snapshots/latest/artifacts/repository/androidx/core/core/maven-metadata.xml", + // packages.atlassian.com 301s to maven.artifacts.atlassian.com, so both + // ends of the chain must be allowed for a restore to complete. + "https://packages.atlassian.com/maven/", + "https://maven.artifacts.atlassian.com/", + "https://julialang-s3.julialang.org/bin/linux/x64/1.10/julia-1.10.0-linux-x86_64.tar.gz", + "https://mirrors.huaweicloud.com/repository/npm/lodash", + "https://pkg.pr.new/tinylibs/tinybench@a832a55", + } { + assert.Nil(t, egressResult(t, h, allowed), "public ecosystem host allowed: "+allowed) + } + + // The maven-central entries are exact virtual-hosted buckets. Adding them + // must not make any other bucket reachable as a subdomain, which is the one + // way this change could regress the storage.googleapis.com apex exception. + for _, blocked := range []string{ + "https://attacker.storage.googleapis.com/payload", + "https://attacker.storage-download.googleapis.com/payload", + "https://evil.maven-central.storage.googleapis.com/payload", + } { + resp := egressResult(t, h, blocked) + if assert.NotNil(t, resp, "bucket subdomains must stay blocked: "+blocked) { + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + } + } + + // Cloudsmith stays blocked: it is the documented precedent for a shared + // host whose tenant lives in the path and whose request logs are visible to + // the tenant. Adding public mirrors must not erode that rule. + resp := egressResult(t, h, "https://dl.cloudsmith.io/org/repo/npm/left-pad") + if assert.NotNil(t, resp, "dl.cloudsmith.io must stay blocked") { + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + } +} + func TestEgressAllowlist_MultiTenantAWSNamespacesNotGloballyAllowed(t *testing.T) { // A 12-digit AWS account id matches every AWS tenant, so ECR and CodeArtifact // are NOT globally allowlisted (an attacker could use their own account). @@ -521,6 +638,13 @@ func TestEgressAllowlist_NewEntriesDoNotWidenBeyondExactHosts(t *testing.T) { "https://evil.codeberg.org/owner/repo", "https://evil.gitlab.com/group/project", "https://evil.releases.bazel.build/payload", + "https://evil.data.nuget.org/payload", + "https://evil.repo.osgeo.org/repository", + "https://evil.androidx.dev/snapshots", + "https://evil.pkg.pr.new/owner/repo", + "https://evil.julialang-s3.julialang.org/bin", + "https://evil.maven.artifacts.atlassian.com/maven", + "https://evil.mirrors.huaweicloud.com/repository/npm", } // Sibling hosts: names sharing a parent with an added entry. These pin the @@ -531,6 +655,11 @@ func TestEgressAllowlist_NewEntriesDoNotWidenBeyondExactHosts(t *testing.T) { "https://attacker.pkg.julialang.org/registries", "https://attacker.digicert.com/payload", "https://attacker.bazel.build/payload", + "https://attacker.nuget.org/payload", + "https://attacker.osgeo.org/repository", + "https://attacker.artifacts.atlassian.com/maven", + "https://attacker.huaweicloud.com/repository/npm", + "https://attacker.julialang.org/bin", // Cloudsmith is multi-tenant with the tenant in the URL path, and the // allowlist authorizes the hostname only. Neither the tenant subdomain // form nor the shared download hosts may be globally allowed. diff --git a/internal/handlers/egress_dynamic_hosts.go b/internal/handlers/egress_dynamic_hosts.go index d7143a8..d3121ba 100644 --- a/internal/handlers/egress_dynamic_hosts.go +++ b/internal/handlers/egress_dynamic_hosts.go @@ -1,12 +1,43 @@ package handlers import ( + "fmt" "net/url" + "regexp" "strings" "github.com/dependabot/proxy/internal/config" ) +// ecrHostPattern matches the canonical private ECR registry host, +// ".dkr.ecr..amazonaws.com", capturing the region. +// +// The region is interpolated into an allowlist entry, so the account is anchored +// to 12 digits and the region to a single dot-free label: a crafted credential +// must not be able to widen the derived host. A path.Match glob cannot serve +// here — it has no capture group, and its "*" spans dots. +var ecrHostPattern = regexp.MustCompile(`^[0-9]{12}\.dkr\.ecr\.([a-z0-9-]+)\.amazonaws\.com$`) + +// registryRedirectHosts returns storage backends that a configured registry +// redirects to on download but that appear in no credential field. +// +// Private ECR 307-redirects layer downloads to a per-region, AWS-owned S3 +// bucket. It is derived per job rather than globbed into the static defaults +// because "prod--starport-layer-bucket" is a claimable S3 name, so a +// glob would hand every job an attacker-registrable destination. +func registryRedirectHosts(credHosts []string) []string { + var hosts []string + for _, h := range credHosts { + m := ecrHostPattern.FindStringSubmatch(h) + if m == nil { + continue + } + region := m[1] + hosts = append(hosts, fmt.Sprintf("prod-%s-starport-layer-bucket.s3.%s.amazonaws.com", region, region)) + } + return hosts +} + // credentialHostKeys are the credential fields that carry a registry host or // URL. The host of each is added to the per-job allowlist so that the private // registries a job is configured to use are never treated as exfiltration. @@ -95,11 +126,19 @@ func oidcExchangeHosts(creds config.Credentials) []string { } // dynamicHosts returns the deduplicated per-job hosts derived from the job's -// credentials: configured registries and OIDC token-exchange endpoints. +// credentials: configured registries, OIDC token-exchange endpoints, and the +// storage backends those registries redirect to for content downloads. func dynamicHosts(creds config.Credentials) []string { + credHosts := credentialHosts(creds) + + all := make([]string, 0, len(credHosts)) + all = append(all, credHosts...) + all = append(all, oidcExchangeHosts(creds)...) + all = append(all, registryRedirectHosts(credHosts)...) + seen := make(map[string]struct{}) var out []string - for _, h := range append(credentialHosts(creds), oidcExchangeHosts(creds)...) { + for _, h := range all { if _, ok := seen[h]; ok { continue } diff --git a/internal/handlers/egress_dynamic_hosts_test.go b/internal/handlers/egress_dynamic_hosts_test.go index 82888d5..f007da7 100644 --- a/internal/handlers/egress_dynamic_hosts_test.go +++ b/internal/handlers/egress_dynamic_hosts_test.go @@ -98,3 +98,59 @@ func TestDynamicHosts_Deduplicates(t *testing.T) { got := dynamicHosts(creds) assert.Equal(t, []string{"npm.example.com"}, got) } + +func TestRegistryRedirectHosts_ECRStarportBucketDerived(t *testing.T) { + // Private ECR 307-redirects layer downloads to a per-region AWS-owned S3 + // bucket that appears in no credential field, so it is derived from the + // region named by the job's own ECR credential. + creds := config.Credentials{ + {"type": "docker_registry", "registry": "123456789012.dkr.ecr.eu-west-1.amazonaws.com"}, + } + h := newEgressHandlerWithCreds(creds) + + assert.Nil(t, egressResult(t, h, "https://123456789012.dkr.ecr.eu-west-1.amazonaws.com/v2/chart/manifests/1.0.0"), + "the ECR registry itself must be allowed") + assert.Nil(t, egressResult(t, h, "https://prod-eu-west-1-starport-layer-bucket.s3.eu-west-1.amazonaws.com/blob?X-Amz-Signature=x"), + "the ECR layer bucket for the credential's region must be allowed") + + for _, blocked := range []string{ + // Only the region the job actually uses is opened. + "https://prod-us-east-1-starport-layer-bucket.s3.us-east-1.amazonaws.com/loot", + // Dynamic hosts are matched exactly, so no child or lookalike widens it. + "https://evil.prod-eu-west-1-starport-layer-bucket.s3.eu-west-1.amazonaws.com/loot", + "https://prod-eu-west-1-starport-layer-bucket.s3.amazonaws.com/loot", + // The shared parent namespace stays closed. + "https://attacker-bucket.s3.eu-west-1.amazonaws.com/loot", + } { + assert.NotNil(t, egressResult(t, h, blocked), "must remain blocked: "+blocked) + } +} + +func TestRegistryRedirectHosts_OnlyCanonicalECRHosts(t *testing.T) { + // The region is interpolated into an allowlist entry, so the pattern must + // not match anything an attacker-supplied credential could bend. + none := []string{ + "public.ecr.aws", // public ECR has no starport backend + "12345.dkr.ecr.eu-west-1.amazonaws.com", // account id must be 12 digits + "123456789012.dkr.ecr.amazonaws.com", // missing region + "123456789012.dkr.ecr.a.b.amazonaws.com", // region must be a single label + "123456789012.dkr.ecr.eu-west-1.amazonaws.com.cn", // different partition + "123456789012.dkr.ecr.eu-west-1.evil.com", // suffix must be amazonaws.com + "evil.com", + } + for _, h := range none { + assert.Empty(t, registryRedirectHosts([]string{h}), "must derive nothing from %q", h) + } + + assert.Equal(t, + []string{"prod-us-east-2-starport-layer-bucket.s3.us-east-2.amazonaws.com"}, + registryRedirectHosts([]string{"123456789012.dkr.ecr.us-east-2.amazonaws.com"})) +} + +func TestRegistryRedirectHosts_NotAddedWithoutECRCredential(t *testing.T) { + h := newEgressHandlerWithCreds(config.Credentials{ + {"type": "docker_registry", "registry": "https://registry.internal.example.com"}, + }) + assert.NotNil(t, egressResult(t, h, "https://prod-eu-west-1-starport-layer-bucket.s3.eu-west-1.amazonaws.com/loot"), + "layer bucket must not be allowed for a job with no ECR credential") +}