From b829da630100ac27e1fdb59c77a2e8bb3c81851c Mon Sep 17 00:00:00 2001 From: v-abhishekbhaskar Date: Wed, 30 Sep 2026 01:33:33 -0500 Subject: [PATCH] add oci registries and changelog release notes registries to egress allowlist --- .../handlers/egress_allowlist_defaults.yaml | 44 ++++++- internal/handlers/egress_allowlist_test.go | 120 +++++++++++++++++- 2 files changed, 161 insertions(+), 3 deletions(-) diff --git a/internal/handlers/egress_allowlist_defaults.yaml b/internal/handlers/egress_allowlist_defaults.yaml index fecfa4f..a296e30 100644 --- a/internal/handlers/egress_allowlist_defaults.yaml +++ b/internal/handlers/egress_allowlist_defaults.yaml @@ -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 @@ -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 @@ -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 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 @@ -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//), so every + # subdomain is Red Hat-operated and none is user-creatable. + - .quay.io - public.ecr.aws - lscr.io - .gcr.io @@ -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 @@ -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 diff --git a/internal/handlers/egress_allowlist_test.go b/internal/handlers/egress_allowlist_test.go index 845387c..586e9d6 100644 --- a/internal/handlers/egress_allowlist_test.go +++ b/internal/handlers/egress_allowlist_test.go @@ -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) } @@ -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", @@ -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//): a user cannot provision .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 @@ -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) } @@ -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) } } @@ -1027,3 +1097,51 @@ func allDefaultDomains() []string { } return hosts } + +// Changelog and release-note hosts are reached by following package metadata +// (PyPI project_urls, POM ), 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", + } { + resp := egressResult(t, h, blocked) + if assert.NotNil(t, resp, "must stay blocked: "+blocked) { + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + } + } +}