diff --git a/internal/handlers/egress_allowlist_defaults.yaml b/internal/handlers/egress_allowlist_defaults.yaml index 4a27a5a..e80fddf 100644 --- a/internal/handlers/egress_allowlist_defaults.yaml +++ b/internal/handlers/egress_allowlist_defaults.yaml @@ -243,6 +243,9 @@ ecosystem_default_domains: # the chain are required. - cloud.google.com - docs.cloud.google.com + - click.palletsprojects.com + - pytest-mock.readthedocs.io + - redis.readthedocs.io uv: *python_registries bundler: - rubygems.org @@ -292,6 +295,30 @@ ecosystem_default_domains: # packages.atlassian.com 301-redirects to maven.artifacts.atlassian.com. - packages.atlassian.com - maven.artifacts.atlassian.com + # Public vendor/community Maven repositories, verified anonymous: each + # returns 404 (not 401) for an absent artifact, so no auth is demanded. + - repo.opencollab.dev + - maven.restlet.talend.com + - maven.fpregistry.io + - repo.essentialsx.net + - repo.dmulloy2.net + - api.xposed.info + - packages.nuxeo.com + - maven.fullstory.com + - maven.lokalise.com + - repository.medallia.com + - jogamp.org + # JCenter is retired but still 301-redirects to repo1.maven.org, which is + # already allowed; only the entry point of the chain needs listing. + - jcenter.bintray.com + # Exact only. GitHub Pages hosts this vendor's Maven repo, but "*.github.io" + # is user-creatable, so a leading-dot entry would be an exfiltration channel. + - salesforce-marketingcloud.github.io + # Exact virtual-hosted S3 bucket (Automattic's public Android libraries). + # Never the path-style "s3.amazonaws.com" apex, which reaches every bucket. + - a8c-libs.s3.amazonaws.com + # Changelog/release-note host for Log4j and friends, not a registry. + - logging.apache.org gradle: *jvm_registries sbt: - repo1.maven.org @@ -418,6 +445,8 @@ ecosystem_default_domains: # possible exfiltration destination. They are retained only # because NuGet restores rely on these rotating CDN hosts. - "*vsblobprod*.blob.core.windows.net" + # Public community NuGet feed, serving an anonymous v3 service index. + - pkg.kzu.app - "*.vsblob.vsassets.io" # Certificate revocation endpoints. NuGet validates author/repository # signatures on restore, so blocking these stalls or fails verification of @@ -450,6 +479,8 @@ ecosystem_default_domains: # Terraform CLI version check. Alternative: set CHECKPOINT_DISABLE=1 in the # updater image and leave this blocked. - checkpoint-api.hashicorp.com + # Module archive downloads from the public registry. + - archivist.terraform.io opentofu: - registry.opentofu.org - releases.hashicorp.com @@ -471,6 +502,16 @@ ecosystem_default_domains: - us-west.pkg.julialang.org # Official Julia release/artifact storage. - julialang-s3.julialang.org + # storage.julialang.net 302-redirects package tarballs to the exact + # virtual-hosted bucket below, so both ends of the chain are required. + # + # The bucket is pinned exactly rather than globbed over the region: only + # "julialang-storage-us-east-1" exists today, and sibling names such as + # "julialang-storage-eu-west-1" are unclaimed, so a + # "julialang-storage-*.s3.*.amazonaws.com" pattern would point at an + # attacker-registrable bucket. If Julia adds a region, add it here. + - storage.julialang.net + - julialang-storage-us-east-1.s3.us-east-1.amazonaws.com rust_toolchain: - static.rust-lang.org conda: diff --git a/internal/handlers/egress_allowlist_test.go b/internal/handlers/egress_allowlist_test.go index 6f7d29a..7af8f6f 100644 --- a/internal/handlers/egress_allowlist_test.go +++ b/internal/handlers/egress_allowlist_test.go @@ -1248,3 +1248,136 @@ func TestEgressAllowlist_ChangelogHostsSecondWaveAllowed(t *testing.T) { } } } + +func TestEgressAllowlist_PublicRegistriesThirdWaveAllowed(t *testing.T) { + h := newEgressHandler(false, true, "") + + for _, allowed := range []string{ + "https://repo.opencollab.dev/maven-releases/org/geysermc/geyser/maven-metadata.xml", + "https://maven.restlet.talend.com/org/restlet/jse/org.restlet/maven-metadata.xml", + "https://maven.fpregistry.io/releases/io/fairyproject/maven-metadata.xml", + "https://repo.essentialsx.net/releases/net/essentialsx/EssentialsX/maven-metadata.xml", + "https://repo.dmulloy2.net/repository/public/com/comphenix/protocol/ProtocolLib/maven-metadata.xml", + "https://api.xposed.info/api/de/robv/android/xposed/api/maven-metadata.xml", + "https://packages.nuxeo.com/repository/maven-public/org/nuxeo/maven-metadata.xml", + "https://maven.fullstory.com/com/fullstory/gradle-plugin/maven-metadata.xml", + "https://maven.lokalise.com/com/lokalise/sdk/maven-metadata.xml", + "https://repository.medallia.com/artifactory/public/com/medallia/maven-metadata.xml", + "https://jogamp.org/deployment/maven/org/jogamp/gluegen/maven-metadata.xml", + "https://jcenter.bintray.com/com/google/guava/guava/maven-metadata.xml", + "https://salesforce-marketingcloud.github.io/MarketingCloudSDK-Android/maven-metadata.xml", + "https://a8c-libs.s3.amazonaws.com/android/com/automattic/maven-metadata.xml", + "https://pkg.kzu.app/index.json", + "https://archivist.terraform.io/v1/object/abc123", + "https://storage.julialang.net/registries/23338594-aafe-5451-b93e-139f81909106", + "https://julialang-storage-us-east-1.s3.us-east-1.amazonaws.com/package/abc", + } { + assert.Nil(t, egressResult(t, h, allowed), "public registry allowed: "+allowed) + } + + for _, blocked := range []string{ + // Path-style shared object storage must never be allowlisted: the apex + // reaches every bucket, so only the exact virtual-hosted bucket is listed. + "https://s3.amazonaws.com/attacker-bucket/payload", + "https://s3.us-east-1.amazonaws.com/attacker-bucket/payload", + "https://s3-us-west-2.amazonaws.com/attacker-bucket/payload", + // Sibling Julia bucket names are unclaimed and therefore registrable, so + // the region must never be globbed. + "https://julialang-storage-eu-west-1.s3.eu-west-1.amazonaws.com/payload", + "https://julialang-storage-evil.s3.us-east-1.amazonaws.com/payload", + // github.io subdomains are user-creatable; one Pages repo must not + // expose the namespace or any sibling. + "https://evil.salesforce-marketingcloud.github.io/payload", + // Hosts that return 401 need a registries: credential. Allowlisting the + // public set must not quietly cover them. + "https://mobile-sdks.forter.com/android/maven-metadata.xml", + "https://nuget.devexpress.com/api/v3/index.json", + // Commercial feeds tenanted by a license key in the path. + "https://nuget.hangfire.io/pro/v3/index.json", + "https://nuget.abp.io/key/v3/index.json", + "https://registry.nes.herodevs.com/angular/core", + "https://connect.advancedcustomfields.com/v1/plugins/download", + // Application Insights telemetry ingestion, not a registry. + "https://dc.services.visualstudio.com/v2/track", + // Retired Bintray download host; only the JCenter redirector is listed. + "https://dl.bintray.com/payload", + // Every exact entry gets a child probe so a later widening to a + // leading-dot suffix cannot pass silently. + "https://evil.repo.opencollab.dev/payload", + "https://evil.maven.restlet.talend.com/payload", + "https://evil.maven.fpregistry.io/payload", + "https://evil.repo.essentialsx.net/payload", + "https://evil.repo.dmulloy2.net/payload", + "https://evil.api.xposed.info/payload", + "https://evil.packages.nuxeo.com/payload", + "https://evil.maven.fullstory.com/payload", + "https://evil.maven.lokalise.com/payload", + "https://evil.repository.medallia.com/payload", + "https://evil.jogamp.org/payload", + "https://evil.jcenter.bintray.com/payload", + "https://evil.pkg.kzu.app/payload", + "https://evil.archivist.terraform.io/payload", + "https://evil.storage.julialang.net/payload", + // The two exact S3 entries need child probes of their own: the sibling + // and apex probes above catch a glob or a path-style widening, but only + // these catch the entry being changed to a leading-dot suffix. + "https://evil.a8c-libs.s3.amazonaws.com/payload", + "https://evil.julialang-storage-us-east-1.s3.us-east-1.amazonaws.com/payload", + // Lookalike parents and suffix-appending attacker domains. + "https://bintray.com/payload", + "https://talend.com/payload", + "https://nuxeo.com/payload", + "https://fullstory.com/payload", + "https://lokalise.com/payload", + "https://medallia.com/payload", + "https://essentialsx.net/payload", + "https://xposed.info/payload", + "https://julialang.net/payload", + "https://archivist.terraform.io.attacker.com/payload", + } { + resp := egressResult(t, h, blocked) + if assert.NotNil(t, resp, "must stay blocked: "+blocked) { + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + } + } +} + +func TestEgressAllowlist_ChangelogHostsThirdWaveAllowed(t *testing.T) { + h := newEgressHandler(false, true, "") + + for _, allowed := range []string{ + "https://click.palletsprojects.com/en/stable/changes/", + "https://pytest-mock.readthedocs.io/en/latest/changelog.html", + "https://redis.readthedocs.io/en/stable/", + "https://logging.apache.org/log4j/2.x/release-notes.html", + } { + assert.Nil(t, egressResult(t, h, allowed), "changelog host allowed: "+allowed) + } + + for _, blocked := range []string{ + // readthedocs.io subdomains are project-creatable, so each entry stays + // exact and the namespace itself must never resolve. + "https://readthedocs.io/payload", + "https://evil.readthedocs.io/payload", + "https://evil.pytest-mock.readthedocs.io/payload", + "https://evil.redis.readthedocs.io/payload", + // Child probes guard against a later widening to a leading-dot suffix. + "https://evil.click.palletsprojects.com/payload", + "https://evil.logging.apache.org/payload", + // Lookalike parents and suffix-appending attacker domains. + "https://palletsprojects.com/payload", + "https://logging.apache.org.attacker.com/payload", + // Issue trackers and code browsers carry no renderable changelog. + "https://issues.apache.org/jira/browse/LOG4J2-1", + "https://cs.android.com/android/platform/superproject", + "https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/home.html", + // Chat and link-aggregator hosts reached via project_urls metadata. + "https://gitter.im/org/room", + "https://www.reddit.com/r/python/", + } { + resp := egressResult(t, h, blocked) + if assert.NotNil(t, resp, "must stay blocked: "+blocked) { + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + } + } +}