fix(provider): drop the relay's capabilities to the strict minimum

The relay only dials an upstream and forwards bytes: it needs none of
Docker's default Linux capabilities. Run it with CapDrop: ["ALL"],
keeping only NET_BIND_SERVICE back via CapAdd since a route commonly
targets a privileged port (e.g. 80, 443) that the relay -- running
unprivileged as UID 65532 -- must still be able to listen on inside
its own container.

Validated end-to-end against a real provider project: a container on
the relay's network reaches the provider through its published, low
port with the hardened capability set in place.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Signed-off-by: Nicolas De Loof <[email protected]>
This commit is contained in:
Nicolas De Loof
2026-09-17 17:51:23 +02:00
committed by Guillaume Lours
co-authored by Claude Sonnet 5
parent bb22a260e8
commit 95929928c1
2 changed files with 45 additions and 0 deletions
+7
View File
@@ -332,6 +332,13 @@ func (s *composeService) createRelayContainer(ctx context.Context, project *type
RestartPolicy: container.RestartPolicy{
Name: container.RestartPolicyUnlessStopped,
},
// The relay only dials out and forwards bytes: it needs none of
// Docker's default capabilities. NET_BIND_SERVICE is kept because a
// route commonly targets a privileged port (e.g. 80, 443) that the
// relay — running unprivileged as UID 65532 — must still be able to
// listen on inside its own container.
CapDrop: []string{"ALL"},
CapAdd: []string{"NET_BIND_SERVICE"},
}
// First network at creation, remaining ones connected afterwards — the
+38
View File
@@ -17,6 +17,7 @@
package compose
import (
"context"
"testing"
"github.com/compose-spec/compose-go/v2/types"
@@ -276,6 +277,43 @@ func TestRemoveServiceRelayRemovesExisting(t *testing.T) {
assert.NilError(t, svc.removeServiceRelay(t.Context(), "p", "db"))
}
// The relay only dials out and forwards bytes: it gets none of Docker's
// default capabilities except NET_BIND_SERVICE, kept because a route
// commonly targets a privileged port the relay must still listen on.
func TestCreateRelayContainerDropsCapabilities(t *testing.T) {
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
apiMock, cli := prepareMocks(mockCtrl)
tested, err := NewComposeService(cli)
assert.NilError(t, err)
svc := tested.(*composeService)
project := &types.Project{
Name: "p",
Networks: types.Networks{"default": {Name: "p_default"}},
}
db := types.ServiceConfig{Name: "db", Provider: &types.ServiceProviderConfig{Type: "test"}}
apiMock.EXPECT().ContainerList(gomock.Any(), gomock.Any()).
Return(client.ContainerListResult{}, nil)
var got client.ContainerCreateOptions
apiMock.EXPECT().ContainerCreate(gomock.Any(), gomock.Any()).
DoAndReturn(func(_ context.Context, opts client.ContainerCreateOptions) (client.ContainerCreateResult, error) {
got = opts
return client.ContainerCreateResult{ID: "relay-1"}, nil
})
apiMock.EXPECT().ContainerStart(gomock.Any(), "relay-1", gomock.Any()).
Return(client.ContainerStartResult{}, nil)
endpoints := map[int]string{80: "host.docker.internal:49152"}
err = svc.ensureServiceRelay(t.Context(), project, db, endpoints, []string{"default"})
assert.NilError(t, err)
assert.DeepEqual(t, got.HostConfig.CapDrop, []string{"ALL"})
assert.DeepEqual(t, got.HostConfig.CapAdd, []string{"NET_BIND_SERVICE"})
}
// No relay ever existed for the service: nothing to do, and nothing calls
// ContainerRemove (mockCtrl.Finish would fail an unexpected call anyway).
func TestRemoveServiceRelayNoopWhenNoneExists(t *testing.T) {