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: 42 additions & 2 deletions internal/handlers/egress_allowlist_defaults.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,9 @@ ecosystem_default_domains:
# 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
# Backstage's published version manifests, read by the Backstage CLI during
# upgrades. Anonymous, no redirects.
- versions.backstage.io
bun: *npm_registries
pip: &python_registries
- pypi.org
Expand All @@ -215,6 +218,21 @@ ecosystem_default_domains:
- pypi.tuna.tsinghua.edu.cn
- download-r2.pytorch.org
- flashinfer.ai
# Changelog/release-note hosts, not registries. Dependabot follows the
# project_urls it reads from PyPI metadata to render release notes in pull
# requests; blocking these degrades PR bodies but never breaks resolution.
# All exact: readthedocs.io subdomains in particular are project-creatable,
# so ".readthedocs.io" must never be used here.
- docs.pytest.org
- docs.sqlalchemy.org
- alembic.sqlalchemy.org
- anyio.readthedocs.io
# docs.pydantic.dev 301-redirects to pydantic.dev, and psycopg.org 302s to
# www.psycopg.org, so both ends of each chain are required.
- docs.pydantic.dev
- pydantic.dev
- psycopg.org
- www.psycopg.org
uv: *python_registries
bundler:
- rubygems.org
Expand All @@ -240,9 +258,18 @@ ecosystem_default_domains:
# the "maven-central" bucket is already owned, so it cannot be claimed.
- maven-central.storage.googleapis.com
- maven-central.storage-download.googleapis.com
- maven-central-eu.storage-download.googleapis.com
# Public vendor/community Maven repositories, served anonymously.
- repo.osgeo.org
- androidx.dev
# Vaadin's release/add-on repositories; anonymous, no redirects.
- maven.vaadin.com
# Mojang's library host for Minecraft mod builds; anonymous, no redirects.
- libraries.minecraft.net
# Changelog/release-note hosts, not registries; sourced from POM <url> and
# AndroidX release pages. See the note in the pip list above.
- commons.apache.org
- developer.android.com
# packages.atlassian.com 301-redirects to maven.artifacts.atlassian.com.
- packages.atlassian.com
- maven.artifacts.atlassian.com
Expand Down Expand Up @@ -318,7 +345,9 @@ ecosystem_default_domains:
- production.cloudfront.docker.com
- ghcr.io
- mcr.microsoft.com
- quay.io
# Leading-dot: Quay tenancy is path-based (quay.io/<org>/<repo>), so every
# subdomain is Red Hat-operated and none is user-creatable.
- .quay.io
- public.ecr.aws
- lscr.io
- .gcr.io
Expand All @@ -334,6 +363,15 @@ ecosystem_default_domains:
- docker.elastic.co
- docker-auth.elastic.co
- docker-registry-production.d24a988e385e0074d717b6bdaea58f0d.r2.cloudflarestorage.com
# Chainguard. Its token service is cgr.dev/token (same host); blobs
# 307-redirect to Chainguard's own R2 account, exact for the reason above.
- cgr.dev
- 9236a389bd48b984df91adc1bc924620.r2.cloudflarestorage.com
# Red Hat's anonymous UBI registry: /v2/ answers 200 with no challenge, so
# there is no token service. Blobs 302-redirect to cdn01.quay.io, covered
# by .quay.io above. registry.redhat.io is deliberately absent: it requires
# a Red Hat login and so belongs in `registries:`.
- registry.access.redhat.com
docker_compose: *docker_registries
nuget:
- api.nuget.org
Expand Down Expand Up @@ -432,7 +470,9 @@ ecosystem_default_domains:
- production.cloudfront.docker.com
- ghcr.io
- mcr.microsoft.com
- quay.io
# Leading-dot for the same reason as the docker list: Quay tenancy is
# path-based, so its cdnNN blob hosts are provider-controlled.
- .quay.io
- public.ecr.aws
- .gcr.io
- .pkg.dev
Expand Down
120 changes: 119 additions & 1 deletion internal/handlers/egress_allowlist_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,14 @@ func TestEgressAllowlist_PublicVendorOCIRegistriesAllowed(t *testing.T) {
"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",
// Chainguard: registry, same-host token service, and its own R2 account.
"https://cgr.dev/v2/chainguard/wolfi-base/manifests/latest",
"https://cgr.dev/token?scope=repository:chainguard/wolfi-base:pull&service=cgr.dev",
"https://9236a389bd48b984df91adc1bc924620.r2.cloudflarestorage.com/chainguard-images-prod/sha256%3Aabc",
// Red Hat UBI: anonymous, no token service, blobs land on the Quay CDN.
"https://registry.access.redhat.com/v2/ubi9/ubi-minimal/tags/list",
"https://registry.access.redhat.com/v2/ubi9/ubi-minimal/manifests/latest",
"https://cdn01.quay.io/quayio-production-s3/sha256/33/33f1abc",
} {
assert.Nil(t, egressResult(t, h, allowed), "public vendor OCI host allowed: "+allowed)
}
Expand All @@ -265,6 +273,11 @@ func TestEgressAllowlist_PublicVendorOCIRegistriesAllowed(t *testing.T) {
"https://evil.docker.elastic.co/v2/",
"https://evil.docker.getcollate.io/v2/",
"https://evil.docker-registry-production.d24a988e385e0074d717b6bdaea58f0d.r2.cloudflarestorage.com/loot",
"https://evil.cgr.dev/v2/",
"https://evil.registry.access.redhat.com/v2/",
// R2 is multi-tenant: only Elastic's and Chainguard's own account hashes
// are allowed, never a sibling account or the parent domain.
"https://evil.9236a389bd48b984df91adc1bc924620.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",
Expand All @@ -279,6 +292,44 @@ func TestEgressAllowlist_PublicVendorOCIRegistriesAllowed(t *testing.T) {
}
}

// TestEgressAllowlist_QuayCDNSubdomainsAllowed pins the deliberate leading-dot
// entry for Quay. The exact apex entry that preceded it did NOT match the
// cdn01-cdn04.quay.io blob CDN, so pulls from any quay.io-hosted image were
// blocked at the blob step while tag discovery appeared to work.
//
// The leading-dot form is safe here specifically because Quay's tenancy is
// path-based (quay.io/<org>/<repo>): a user cannot provision <name>.quay.io, so
// no matched label is attacker-choosable. Do not copy this to a registry that
// hands out subdomains.
func TestEgressAllowlist_QuayCDNSubdomainsAllowed(t *testing.T) {
h := newEgressHandler(false, true, "docker")

for _, allowed := range []string{
"https://quay.io/v2/prometheus/busybox/tags/list",
"https://cdn01.quay.io/quayio-production-s3/sha256/33/abc",
"https://cdn02.quay.io/quayio-production-s3/sha256/33/abc",
// Quay may add CDN hosts; the suffix form must keep covering them.
"https://cdn99.quay.io/quayio-production-s3/sha256/33/abc",
} {
assert.Nil(t, egressResult(t, h, allowed), "quay host allowed: "+allowed)
}

for _, blocked := range []string{
// The suffix must not be satisfiable by an attacker-registered parent.
"https://quay.io.attacker.com/v2/",
"https://evil.quay.io.attacker.com/v2/",
"https://notquay.io/v2/",
// Red Hat's authenticated registry is deliberately excluded: it needs a
// Red Hat login, so it belongs in `registries:`, not the defaults.
"https://registry.redhat.io/v2/ubi9/ubi-minimal/manifests/latest",
} {
resp := egressResult(t, h, blocked)
if assert.NotNil(t, resp, "quay entry 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
Expand Down Expand Up @@ -333,6 +384,15 @@ func TestEgressAllowlist_PublicEcosystemMirrorsAllowed(t *testing.T) {
"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",
// Maven Central's EU download mirror, a sibling of the buckets above.
"https://maven-central-eu.storage-download.googleapis.com/maven2/org/slf4j/slf4j-api/2.0.13/slf4j-api-2.0.13.pom",
// Vaadin release/add-on repositories.
"https://maven.vaadin.com/vaadin-addons/org/vaadin/artur/a-vaadin-helper/maven-metadata.xml",
"https://maven.vaadin.com/vaadin-releases/com/vaadin/flow-server/maven-metadata.xml",
// Mojang library host used by Minecraft mod builds.
"https://libraries.minecraft.net/com/mojang/brigadier/1.0.18/brigadier-1.0.18.jar",
// Backstage version manifests.
"https://versions.backstage.io/v1/tags/main/manifest.json",
} {
assert.Nil(t, egressResult(t, h, allowed), "public ecosystem host allowed: "+allowed)
}
Expand All @@ -344,9 +404,19 @@ func TestEgressAllowlist_PublicEcosystemMirrorsAllowed(t *testing.T) {
"https://attacker.storage.googleapis.com/payload",
"https://attacker.storage-download.googleapis.com/payload",
"https://evil.maven-central.storage.googleapis.com/payload",
"https://evil.maven-central-eu.storage-download.googleapis.com/payload",
// The new vendor entries are exact hosts: no child may inherit them,
// and no lookalike parent may match them.
"https://evil.maven.vaadin.com/payload",
"https://maven.vaadin.com.attacker.com/payload",
"https://vaadin.com/payload",
"https://evil.libraries.minecraft.net/payload",
"https://libraries.minecraft.net.attacker.com/payload",
"https://evil.versions.backstage.io/payload",
"https://backstage.io/payload",
} {
resp := egressResult(t, h, blocked)
if assert.NotNil(t, resp, "bucket subdomains must stay blocked: "+blocked) {
if assert.NotNil(t, resp, "exact entries must not widen: "+blocked) {
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
}
}
Expand Down Expand Up @@ -1027,3 +1097,51 @@ func allDefaultDomains() []string {
}
return hosts
}

// Changelog and release-note hosts are reached by following package metadata
// (PyPI project_urls, POM <url>), not by resolving dependencies. They are
// allowed so pull requests keep their release notes; every entry is exact.
func TestEgressAllowlist_ChangelogHostsAllowed(t *testing.T) {
h := newEgressHandler(false, true, "")

for _, allowed := range []string{
"https://docs.pytest.org/en/stable/changelog.html",
"https://docs.sqlalchemy.org/en/20/changelog/",
"https://alembic.sqlalchemy.org/en/latest/changelog.html",
"https://anyio.readthedocs.io/en/stable/versionhistory.html",
"https://commons.apache.org/proper/commons-lang/changes.html",
"https://developer.android.com/jetpack/androidx/releases/core",
// Both ends of the two cross-host redirect chains.
"https://docs.pydantic.dev/latest/changelog/",
"https://pydantic.dev/docs/validation/latest/get-started/changelog/",
"https://psycopg.org/docs/news.html",
"https://www.psycopg.org/docs/news.html",
} {
assert.Nil(t, egressResult(t, h, allowed), "changelog host allowed: "+allowed)
}

for _, blocked := range []string{
// readthedocs.io subdomains are project-creatable, so allowing one
// project must not expose the namespace or its apex.
"https://evil.readthedocs.io/payload",
"https://readthedocs.io/payload",
"https://evil.anyio.readthedocs.io/payload",
// Issue trackers, code browsers and vendor doc portals are deliberately
// excluded: they carry no changelog Dependabot renders.
"https://issues.apache.org/jira/browse/LANG",
"https://cs.android.com/android/platform/superproject",
"https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/home.html",
// Exact entries must not widen to children or lookalike parents.
"https://evil.docs.pytest.org/payload",
"https://docs.pytest.org.attacker.com/payload",
"https://pytest.org/payload",
"https://evil.developer.android.com/payload",
"https://android.com/payload",
"https://apache.org/payload",
Comment on lines +1134 to +1140
} {
resp := egressResult(t, h, blocked)
if assert.NotNil(t, resp, "must stay blocked: "+blocked) {
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
}
}
}
Loading