Skip to content
Closed
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
22 changes: 17 additions & 5 deletions internal/handlers/egress_dynamic_hosts.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,34 @@ 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.
//
// Private ECR 307-redirects layer downloads to a per-region, AWS-owned S3
// bucket. It is derived per job rather than globbed into the static defaults
// because "prod-<anything>-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
}
Expand Down
61 changes: 61 additions & 0 deletions internal/handlers/egress_dynamic_hosts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
Loading