Skip to content
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/dependabot-proxy
/proxy
/config.json
/tmp
/tmp
/.vscode
86 changes: 86 additions & 0 deletions internal/handlers/egress_allowlist.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package handlers

import (
"net/http"
"strings"

"github.com/elazarl/goproxy"

"github.com/dependabot/proxy/internal/config"
"github.com/dependabot/proxy/internal/helpers"
"github.com/dependabot/proxy/internal/logging"
)

// Experiment flags (job experiments) that toggle egress filtering. They are
// independent: observe logs non-allowlisted hosts, enforce drops them with a
// 403. Both default off (fail-open) when absent.
const (
egressObserveExperiment = "proxy_egress_observe"
egressEnforceExperiment = "proxy_egress_enforce"
)

// EgressAllowlistHandler filters outbound requests against a per-job allowlist
// of non-hostile domains. In observe mode it only logs non-allowlisted hosts;
// in enforce mode it drops them with a 403. When neither flag is set it allows
// all traffic (fail-open).
type EgressAllowlistHandler struct {
observe bool
enforce bool
allowed []string
}

// NewEgressAllowlistHandler builds the allowlist from the always-allowed GitHub
// infrastructure domains, the union of every ecosystem's default registry hosts,
// and the job's dynamic hosts (configured registries and OIDC token-exchange
// endpoints derived from cfg.Credentials). The observe/enforce toggles are
// driven by job experiments.
func NewEgressAllowlistHandler(cfg *config.Config, env config.ProxyEnvSettings) *EgressAllowlistHandler {
allowed := append([]string(nil), githubInfraDomains...)
allowed = append(allowed, allEcosystemDomains...)
allowed = append(allowed, dynamicHosts(cfg.Credentials)...)

return &EgressAllowlistHandler{
observe: cfg.Experiments.Enabled(egressObserveExperiment),
enforce: cfg.Experiments.Enabled(egressEnforceExperiment),
Comment thread
v-abhishekbhaskar marked this conversation as resolved.
allowed: allowed,
}
}

// HandleRequest logs and/or blocks requests whose host is not on the allowlist.
func (h *EgressAllowlistHandler) HandleRequest(req *http.Request, proxyCtx *goproxy.ProxyCtx) (*http.Request, *http.Response) {
if !h.observe && !h.enforce {
return req, nil
}

host := helpers.GetHost(req)
if host == "" || h.isAllowed(host) {
return req, nil
}

if h.observe {
logging.RequestLogf(proxyCtx, "* egress not allowlisted %s", host)
}
if h.enforce {
return req, goproxy.NewResponse(req, goproxy.ContentTypeText, http.StatusForbidden, "Forbidden")
}
return req, nil
}

func (h *EgressAllowlistHandler) isAllowed(host string) bool {
// Normalize an absolute DNS name (trailing dot) so exact matches treat
// "registry.npmjs.org." as equivalent to "registry.npmjs.org", consistent
// with HostMatchesDomain's boundary handling for the suffix form.
host = strings.TrimSuffix(host, ".")
for _, entry := range h.allowed {
// A leading dot means "this domain and any subdomain"; otherwise the
// entry must match the host exactly.
if domain, ok := strings.CutPrefix(entry, "."); ok {
if helpers.HostMatchesDomain(host, domain) {
return true
}
} else if helpers.AreHostnamesEqual(host, entry) {
Comment thread
v-abhishekbhaskar marked this conversation as resolved.
return true
}
}
return false
}
63 changes: 63 additions & 0 deletions internal/handlers/egress_allowlist_defaults.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package handlers

import (
_ "embed"
"fmt"
"slices"

"gopkg.in/yaml.v3"
)

// egressDefaultsYAML holds the allowlist defaults. It is the single source of
// truth for the GitHub infrastructure domains and per-ecosystem registry hosts;
// see the file itself for the matching convention and provenance notes.
//
//go:embed egress_allowlist_defaults.yaml
var egressDefaultsYAML []byte

// egressDefaults is the parsed representation of egress_allowlist_defaults.yaml.
type egressDefaults struct {
GithubInfraDomains []string `yaml:"github_infra_domains"`
EcosystemDefaultDomains map[string][]string `yaml:"ecosystem_default_domains"`
}

var (
// githubInfraDomains are the GitHub/Dependabot infrastructure domains that
// are always allowed, regardless of ecosystem.
githubInfraDomains []string

// ecosystemDefaultDomains maps each Dependabot ecosystem to the public
// registry/CDN hosts it needs. Retained for provenance/documentation; the
// handler applies the union (allEcosystemDomains), not a per-key lookup.
ecosystemDefaultDomains map[string][]string

// allEcosystemDomains is the deduplicated union of every ecosystem's
// defaults, applied to all jobs. We intentionally do not partition by
// PACKAGE_MANAGER: launchers do not reliably set it, and multi-ecosystem
// jobs need several ecosystems at once. All entries are trusted public
// registries, so the loss of cross-ecosystem isolation is negligible versus
// the exfiltration protection (unknown hosts are still blocked).
allEcosystemDomains []string
)

func init() {
var defaults egressDefaults
if err := yaml.Unmarshal(egressDefaultsYAML, &defaults); err != nil {
panic(fmt.Sprintf("parsing egress_allowlist_defaults.yaml: %v", err))
}

githubInfraDomains = defaults.GithubInfraDomains
ecosystemDefaultDomains = defaults.EcosystemDefaultDomains

seen := make(map[string]struct{})
for _, hosts := range ecosystemDefaultDomains {
for _, host := range hosts {
if _, ok := seen[host]; ok {
continue
}
seen[host] = struct{}{}
allEcosystemDomains = append(allEcosystemDomains, host)
}
}
slices.Sort(allEcosystemDomains)
}
147 changes: 147 additions & 0 deletions internal/handlers/egress_allowlist_defaults.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# Egress allowlist default domains.
#
# Matching convention (see EgressAllowlistHandler): a leading dot (".github.com")
# matches that domain and any subdomain, while an entry without a leading dot
# ("storage.googleapis.com") matches only that exact host. Prefer exact hosts.
# Use the leading-dot form only for domains whose subdomains are entirely
# provider-controlled and never user-creatable, so that user-controlled
# subdomains — e.g. object-storage buckets reachable as
# <bucket>.storage.googleapis.com — cannot be abused as an exfiltration channel.

# github_infra_domains are the GitHub and Dependabot infrastructure domains that
# are always allowed. Their subdomains are entirely GitHub-controlled (api.,
# codeload., objects./raw.githubusercontent.com, api.<tenant>.ghe.com), so the
# leading-dot suffix form is safe here.
github_infra_domains:
- .github.com
- .githubusercontent.com
- .githubapp.com
- .ghe.com
- ghcr.io

# ecosystem_default_domains lists the public registry and CDN hosts each
# Dependabot ecosystem needs. The map is kept keyed by ecosystem for provenance
# and documentation, but the handler applies the UNION of all values to every
# job (it does not partition by PACKAGE_MANAGER). Entries are exact hosts unless
# a leading dot is present. Object-storage hosts (storage.googleapis.com) are
# intentionally exact so user-created buckets under them are not allowlisted.
#
# Every registered Dependabot ecosystem has an entry. An empty list means the
# ecosystem reaches only GitHub/Dependabot infrastructure or plain git (covered
# by github_infra_domains and the job's own git credentials), or its registries
# are configured per job: github_actions, submodules, swift, vcpkg, pre_commit
# (all git/GitHub-hosted) and helm (chart repositories supplied per job). Keys
# must match the ecosystem internal names registered in dependabot-core
# (Dependabot::FileFetchers.register).
ecosystem_default_domains:
npm_and_yarn:
- registry.npmjs.org
- registry.yarnpkg.com
- jsr.io
- registry.bower.io
bun:
- registry.npmjs.org
- registry.yarnpkg.com
- jsr.io
- registry.bower.io
pip:
- pypi.org
- pypi.python.org
- files.pythonhosted.org
- pythonhosted.org
uv:
- pypi.org
- pypi.python.org
- files.pythonhosted.org
- pythonhosted.org
bundler:
- rubygems.org
- index.rubygems.org
- gems.rubygems.org
maven:
- repo.maven.apache.org
- repo1.maven.org
- plugins.gradle.org
- dl.google.com
gradle:
- repo.maven.apache.org
- repo1.maven.org
- plugins.gradle.org
- dl.google.com
sbt:
- repo1.maven.org
- repo.maven.apache.org
- repo.scala-sbt.org
cargo:
- crates.io
- static.crates.io
- index.crates.io
composer:
- repo.packagist.org
- packagist.org
go_modules:
- proxy.golang.org
- sum.golang.org
- storage.googleapis.com
docker: &docker_registries
- registry-1.docker.io
- auth.docker.io
- index.docker.io
- registry.hub.docker.com
- production.cloudflare.docker.com
- ghcr.io
- mcr.microsoft.com
- quay.io
- public.ecr.aws
- .gcr.io
- .pkg.dev
docker_compose: *docker_registries
nuget:
- api.nuget.org
dotnet_sdk:
- api.nuget.org
hex:
- repo.hex.pm
- hex.pm
pub:
- pub.dev
- pub.dartlang.org
- storage.googleapis.com
terraform:
- registry.terraform.io
- releases.hashicorp.com
opentofu:
- registry.opentofu.org
- releases.hashicorp.com
elm:
- package.elm-lang.org
deno:
- jsr.io
- deno.land
- registry.npmjs.org
bazel:
- bcr.bazel.build
julia:
- pkg.julialang.org
rust_toolchain:
- static.rust-lang.org
conda:
- api.anaconda.org
- anaconda.org
- conda.anaconda.org
- repo.anaconda.com
nix:
- channels.nixos.org
devcontainers: *docker_registries
# helm pulls OCI charts from container registries, so it shares the docker set
# (chart-repo HTTP registries are still supplied per job via credentials).
helm: *docker_registries
# GitHub-hosted or per-job configured; covered by github_infra_domains / git.
# Note: additional per-job dependencies (e.g. pre_commit hooks that install
# from other ecosystems) are covered because the handler applies the union of
# ALL ecosystem hosts to every job, not just the job's own package manager.
github_actions: []
submodules: []
swift: []
vcpkg: []
pre_commit: []
Loading