From 9ae04cd8b15defb1966e202dd38050a6b7001c68 Mon Sep 17 00:00:00 2001 From: Chris Burns <29541485+ChrisJBurns@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:47:17 +0100 Subject: [PATCH] Harden Envoy backend: pin image digest, disable admin interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin defaultEnvoyImage to tag+digest for supply-chain integrity. Add a comment documenting the TOOLHIVE_ENVOY_IMAGE override for users with image-policy requirements. Fix PullImage in RegistryImageManager to handle tag@digest references. name.ParseReference returns name.Digest (not name.Tag) for these, and the previous name.NewTag(ref.String()) fallback errored because the @sha256:... suffix is not valid in a tag reference. Now extracts the tag portion (everything before the '@') from the digest reference. Remove the admin interface block from the bootstrap. Envoy does not start an admin server when the field is absent, eliminating the 9901 port surface area with no functional impact on the proxy. Use a structural JSON assertion (unmarshal → key check) instead of substring matching to verify the admin block is absent. Closes #5903 Co-Authored-By: Claude Sonnet 4.6 (1M context) --- pkg/container/docker/envoy.go | 22 +++++-------------- pkg/container/docker/envoy_test.go | 35 ++++++++---------------------- pkg/container/images/registry.go | 29 ++++++++++++++++++++----- 3 files changed, 37 insertions(+), 49 deletions(-) diff --git a/pkg/container/docker/envoy.go b/pkg/container/docker/envoy.go index ef4e298f49..60b0be40d9 100644 --- a/pkg/container/docker/envoy.go +++ b/pkg/container/docker/envoy.go @@ -22,9 +22,9 @@ import ( ) const ( - // defaultEnvoyImage is pinned by tag. Digest pinning is tracked in #5903. - // Override with TOOLHIVE_ENVOY_IMAGE. - defaultEnvoyImage = "envoyproxy/envoy-distroless:v1.32.3" + // defaultEnvoyImage is pinned by tag+digest for supply-chain integrity. + // Override with TOOLHIVE_ENVOY_IMAGE (accepts any docker pull reference). + defaultEnvoyImage = "envoyproxy/envoy-distroless:v1.32.3@sha256:375aab0d80b3c0e1b42a776b4cb1743ed79012032051d2da19cbc93ea884fb81" // Protobuf type URLs required by Envoy's protobuf-JSON bootstrap format. // Every typed_config field must carry an @type URL or Envoy will reject the @@ -56,16 +56,12 @@ func getEnvoyImage() string { // ── Bootstrap ──────────────────────────────────────────────────────────────── // envoyBootstrap is the top-level Envoy bootstrap configuration. +// The admin interface is intentionally omitted: Envoy does not start an admin +// server when the field is absent, eliminating the attack surface at runtime. type envoyBootstrap struct { - Admin *envoyAdmin `json:"admin,omitempty"` StaticResources envoyStaticResources `json:"static_resources"` } -// envoyAdmin configures the Envoy admin API endpoint. -type envoyAdmin struct { - Address envoyAddress `json:"address"` -} - // envoyStaticResources holds the static listeners and clusters. type envoyStaticResources struct { Listeners []envoyListener `json:"listeners,omitempty"` @@ -882,14 +878,6 @@ func (e *envoyProxy) SetupIngress(ctx context.Context, spec proxySpec, _ egressR egressContainerName := fmt.Sprintf("%s-egress", spec.WorkloadName) bootstrap := envoyBootstrap{ - Admin: &envoyAdmin{ - Address: envoyAddress{ - SocketAddress: envoySocketAddress{ - Address: "127.0.0.1", // loopback only — never 0.0.0.0 - PortValue: 9901, - }, - }, - }, StaticResources: envoyStaticResources{ Listeners: []envoyListener{buildEgressListener(spec)}, Clusters: []envoyCluster{buildEgressCluster()}, diff --git a/pkg/container/docker/envoy_test.go b/pkg/container/docker/envoy_test.go index 6353db211c..e5bfafdbd3 100644 --- a/pkg/container/docker/envoy_test.go +++ b/pkg/container/docker/envoy_test.go @@ -550,14 +550,6 @@ func TestWriteEnvoyBootstrap_FileMode(t *testing.T) { t.Parallel() b := envoyBootstrap{ - Admin: &envoyAdmin{ - Address: envoyAddress{ - SocketAddress: envoySocketAddress{ - Address: "127.0.0.1", - PortValue: 9901, - }, - }, - }, StaticResources: envoyStaticResources{}, } @@ -588,21 +580,13 @@ func TestWriteEnvoyBootstrap_FileMode(t *testing.T) { "bootstrap file must contain valid JSON") } -// TestEnvoyAdmin_LoopbackOnly asserts that the admin block written by -// writeEnvoyBootstrap binds only on the loopback address and never on -// 0.0.0.0 or an empty address that would expose admin to all interfaces. -func TestEnvoyAdmin_LoopbackOnly(t *testing.T) { +// TestEnvoyAdmin_Absent asserts that the admin interface is entirely absent from +// the generated bootstrap. Omitting the admin block causes Envoy to skip the +// admin server, removing the attack surface without affecting proxy behaviour. +func TestEnvoyAdmin_Absent(t *testing.T) { t.Parallel() b := envoyBootstrap{ - Admin: &envoyAdmin{ - Address: envoyAddress{ - SocketAddress: envoySocketAddress{ - Address: "127.0.0.1", - PortValue: 9901, - }, - }, - }, StaticResources: envoyStaticResources{}, } @@ -613,12 +597,12 @@ func TestEnvoyAdmin_LoopbackOnly(t *testing.T) { data, err := os.ReadFile(path) require.NoError(t, err) - s := string(data) - assert.Contains(t, s, "127.0.0.1", - "admin address must be loopback 127.0.0.1") - assert.NotContains(t, s, "0.0.0.0", - "admin address must NOT bind on 0.0.0.0") + var m map[string]any + require.NoError(t, json.Unmarshal(data, &m), "bootstrap must be valid JSON") + + _, hasAdmin := m["admin"] + assert.False(t, hasAdmin, "bootstrap must not contain an admin block") } // TestGetEnvoyImage verifies that getEnvoyImage returns the default image when @@ -752,7 +736,6 @@ func TestEnvoyBootstrap_ValidatesAgainstRealEnvoy(t *testing.T) { clusters = append(clusters, buildIngressCluster(tc.spec)) } b := envoyBootstrap{ - Admin: &envoyAdmin{Address: envoyAddress{SocketAddress: envoySocketAddress{Address: "127.0.0.1", PortValue: 9901}}}, StaticResources: envoyStaticResources{Listeners: listeners, Clusters: clusters}, } cfg, err := json.Marshal(b) diff --git a/pkg/container/images/registry.go b/pkg/container/images/registry.go index 1080c790e5..41fec7f754 100644 --- a/pkg/container/images/registry.go +++ b/pkg/container/images/registry.go @@ -14,6 +14,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/name" @@ -97,14 +98,30 @@ func (r *RegistryImageManager) PullImage(ctx context.Context, imageName string) return fmt.Errorf("failed to pull image from registry: %w", err) } - // Convert reference to tag for daemon.Write - tag, ok := ref.(name.Tag) - if !ok { - // If it's not a tag, try to convert to tag - tag, err = name.NewTag(ref.String()) + // Convert reference to a name.Tag for daemon.Write. + // name.ParseReference returns name.Digest (not name.Tag) for tag@digest + // references like "image:tag@sha256:...". In that case we extract the tag + // portion (everything before the '@') so daemon.Write can store the image + // under its human-readable tag, while the pull itself was verified against + // the digest by go-containerregistry. + var tag name.Tag + switch v := ref.(type) { + case name.Tag: + tag = v + case name.Digest: + refStr := ref.String() + if atIdx := strings.Index(refStr, "@"); atIdx >= 0 { + // tag@digest — use the portion before '@' as the tag reference. + tag, err = name.NewTag(refStr[:atIdx]) + } else { + // digest-only (no tag) — fall back to repository:latest. + tag, err = name.NewTag(v.Repository.String()) + } if err != nil { - return fmt.Errorf("failed to convert reference to tag: %w", err) + return fmt.Errorf("failed to derive tag from digest reference: %w", err) } + default: + return fmt.Errorf("unsupported image reference type %T", ref) } // Save the image to the local daemon