From 35e90711c578cf93d505264bbb4c1d4033ebea4f Mon Sep 17 00:00:00 2001 From: Sebastian Bresin Date: Thu, 1 Oct 2026 14:26:39 +0200 Subject: [PATCH] Allow Gemfury storage backend for jobs using a Gemfury registry Gemfury registries (pypi.fury.io, npm.fury.io, ...) 302-redirect package downloads to a pre-signed URL on gemfury.s3-accelerate.dualstack.amazonaws.com. That host appears in no credential field, so the egress allowlist blocks the redirect and the update fails even though the authenticated registry request succeeded. Derive the bucket per job from a *.fury.io credential host, alongside the existing ECR starport bucket, instead of adding it to the static defaults: the bucket is shared by all Gemfury accounts, so a static entry would open every tenant's content to every job. The derived host is a constant, so a crafted credential cannot widen it. --- internal/handlers/egress_dynamic_hosts.go | 22 +++++-- .../handlers/egress_dynamic_hosts_test.go | 61 +++++++++++++++++++ 2 files changed, 78 insertions(+), 5 deletions(-) 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"},