Skip to content
Merged
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
44 changes: 41 additions & 3 deletions internal/handlers/egress_allowlist_defaults.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<bucket>.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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 "<bucket>.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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
129 changes: 129 additions & 0 deletions internal/handlers/egress_allowlist_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
43 changes: 41 additions & 2 deletions internal/handlers/egress_dynamic_hosts.go
Original file line number Diff line number Diff line change
@@ -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,
// "<account-id>.dkr.ecr.<region>.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-<anything>-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.
Expand Down Expand Up @@ -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
}
Expand Down
56 changes: 56 additions & 0 deletions internal/handlers/egress_dynamic_hosts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Loading