diff --git a/internal/handlers/egress_dynamic_hosts.go b/internal/handlers/egress_dynamic_hosts.go index d3121ba..9c279ca 100644 --- a/internal/handlers/egress_dynamic_hosts.go +++ b/internal/handlers/egress_dynamic_hosts.go @@ -18,6 +18,11 @@ import ( // here — it has no capture group, and its "*" spans dots. var ecrHostPattern = regexp.MustCompile(`^[0-9]{12}\.dkr\.ecr\.([a-z0-9-]+)\.amazonaws\.com$`) +// gemfuryStorageHost is the single Gemfury-owned S3 bucket that every Gemfury +// registry (pypi.fury.io, npm.fury.io, ...) 302-redirects package downloads to +// via short-lived pre-signed URLs. +const gemfuryStorageHost = "gemfury.s3-accelerate.dualstack.amazonaws.com" + // registryRedirectHosts returns storage backends that a configured registry // redirects to on download but that appear in no credential field. // @@ -25,15 +30,22 @@ var ecrHostPattern = regexp.MustCompile(`^[0-9]{12}\.dkr\.ecr\.([a-z0-9-]+)\.ama // 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. +// +// Gemfury redirects to one fixed bucket shared by all Gemfury accounts, with +// the account in the URL path. It is derived per job rather than added to the +// static defaults because allowing a shared multi-tenant host there would open +// every tenant's content to every job. The derived host is a constant, so a +// crafted credential cannot widen it. func registryRedirectHosts(credHosts []string) []string { var hosts []string for _, h := range credHosts { - m := ecrHostPattern.FindStringSubmatch(h) - if m == nil { - continue + if m := ecrHostPattern.FindStringSubmatch(h); m != nil { + region := m[1] + hosts = append(hosts, fmt.Sprintf("prod-%s-starport-layer-bucket.s3.%s.amazonaws.com", region, region)) + } + if strings.HasSuffix(h, ".fury.io") { + hosts = append(hosts, gemfuryStorageHost) } - region := m[1] - hosts = append(hosts, fmt.Sprintf("prod-%s-starport-layer-bucket.s3.%s.amazonaws.com", region, region)) } return hosts } diff --git a/internal/handlers/egress_dynamic_hosts_test.go b/internal/handlers/egress_dynamic_hosts_test.go index f007da7..0e366fd 100644 --- a/internal/handlers/egress_dynamic_hosts_test.go +++ b/internal/handlers/egress_dynamic_hosts_test.go @@ -147,6 +147,67 @@ func TestRegistryRedirectHosts_OnlyCanonicalECRHosts(t *testing.T) { registryRedirectHosts([]string{"123456789012.dkr.ecr.us-east-2.amazonaws.com"})) } +func TestRegistryRedirectHosts_GemfuryStorageDerived(t *testing.T) { + // Gemfury 302-redirects package downloads from every registry endpoint to a + // single Gemfury-owned S3 bucket via pre-signed URLs, so the bucket is + // derived from the job's own Gemfury credential. + creds := config.Credentials{ + {"type": "python_index", "index-url": "https://pypi.fury.io/acme/"}, + } + h := newEgressHandlerWithCreds(creds) + + assert.Nil(t, egressResult(t, h, "https://pypi.fury.io/acme/-/ver_x/pkg-1.0.0-py3-none-any.whl"), + "the Gemfury registry itself must be allowed") + assert.Nil(t, egressResult(t, h, "https://gemfury.s3-accelerate.dualstack.amazonaws.com/gems/x/pkg_whl?X-Amz-Signature=x"), + "the Gemfury storage bucket must be allowed") + + for _, blocked := range []string{ + // Dynamic hosts are matched exactly, so no child or lookalike widens it. + "https://evil.gemfury.s3-accelerate.dualstack.amazonaws.com/loot", + "https://gemfuryx.s3-accelerate.dualstack.amazonaws.com/loot", + "https://gemfury.s3.amazonaws.com/loot", + // The shared parent namespace stays closed. + "https://attacker.s3-accelerate.dualstack.amazonaws.com/loot", + } { + assert.NotNil(t, egressResult(t, h, blocked), "must remain blocked: "+blocked) + } +} + +func TestRegistryRedirectHosts_OnlyGemfuryHosts(t *testing.T) { + for _, h := range []string{ + "pypi.fury.io", + "npm.fury.io", + "npm-proxy.fury.io", + "gem.fury.io", + } { + assert.Equal(t, []string{gemfuryStorageHost}, registryRedirectHosts([]string{h}), + "must derive the Gemfury bucket from %q", h) + } + + for _, h := range []string{ + "fury.io", // apex is not a registry endpoint + "pypi.fury.io.evil.com", // suffix must be fury.io + "pypifury.io", + "evil.com", + } { + assert.Empty(t, registryRedirectHosts([]string{h}), "must derive nothing from %q", h) + } + + // Several Gemfury credentials still yield a single entry. + assert.Equal(t, []string{"pypi.fury.io", "npm.fury.io", gemfuryStorageHost}, dynamicHosts(config.Credentials{ + {"type": "python_index", "index-url": "https://pypi.fury.io/acme/"}, + {"type": "npm_registry", "registry": "https://npm.fury.io/acme/"}, + })) +} + +func TestRegistryRedirectHosts_NotAddedWithoutGemfuryCredential(t *testing.T) { + h := newEgressHandlerWithCreds(config.Credentials{ + {"type": "python_index", "index-url": "https://pypi.internal.example.com/simple"}, + }) + assert.NotNil(t, egressResult(t, h, "https://gemfury.s3-accelerate.dualstack.amazonaws.com/gems/x/loot"), + "Gemfury bucket must not be allowed for a job with no Gemfury credential") +} + func TestRegistryRedirectHosts_NotAddedWithoutECRCredential(t *testing.T) { h := newEgressHandlerWithCreds(config.Credentials{ {"type": "docker_registry", "registry": "https://registry.internal.example.com"},