detached 49efad0

feat(security): guard app container configs and disable process dumping

commit
49efad03a5f5400a1b7604437e90789c88407b1b
author
brice · · 2026-10-08 20:32 UTC
parent
e683751
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
17 files · +602 -3
M internal/adapters/in/http/admin/handler_apps.go +2 -0
@@ -559,6 +559,8 @@ func (h *Handler) sendAppOpError(w http.ResponseWriter, err error) {
559559 h.sendAppError(w, http.StatusBadRequest, "bind-policy-violation", err.Error(), "", "check the administrative mount authorization")
560560 case errors.Is(err, domain.ErrDevicePolicy):
561561 h.sendAppError(w, http.StatusBadRequest, "device-policy-violation", err.Error(), "", "check the administrative device authorization")
562+ case errors.Is(err, domain.ErrRuntimeGuard):
563+ h.sendAppError(w, http.StatusBadRequest, "runtime-guard-violation", err.Error(), "", "check administrative mounts and network settings")
562564 case errors.Is(err, domain.ErrRuntimeUnsupported):
563565 h.sendAppError(w, http.StatusBadRequest, "runtime-unsupported", err.Error(), "", "use an engine with native CDI support")
564566 case errors.Is(err, domain.ErrAppReservationConflict):
@@ -559,6 +559,8 @@ func (h *Handler) sendAppOpError(w http.ResponseWriter, err error) {
559 h.sendAppError(w, http.StatusBadRequest, "bind-policy-violation", err.Error(), "", "check the administrative mount authorization")559 h.sendAppError(w, http.StatusBadRequest, "bind-policy-violation", err.Error(), "", "check the administrative mount authorization")
560 case errors.Is(err, domain.ErrDevicePolicy):560 case errors.Is(err, domain.ErrDevicePolicy):
561 h.sendAppError(w, http.StatusBadRequest, "device-policy-violation", err.Error(), "", "check the administrative device authorization")561 h.sendAppError(w, http.StatusBadRequest, "device-policy-violation", err.Error(), "", "check the administrative device authorization")
562+ case errors.Is(err, domain.ErrRuntimeGuard):
563+ h.sendAppError(w, http.StatusBadRequest, "runtime-guard-violation", err.Error(), "", "check administrative mounts and network settings")
562 case errors.Is(err, domain.ErrRuntimeUnsupported):564 case errors.Is(err, domain.ErrRuntimeUnsupported):
563 h.sendAppError(w, http.StatusBadRequest, "runtime-unsupported", err.Error(), "", "use an engine with native CDI support")565 h.sendAppError(w, http.StatusBadRequest, "runtime-unsupported", err.Error(), "", "use an engine with native CDI support")
564 case errors.Is(err, domain.ErrAppReservationConflict):566 case errors.Is(err, domain.ErrAppReservationConflict):
M internal/adapters/in/http/admin/handler_apps_test.go +17 -0
@@ -682,6 +682,23 @@ func TestHandler_AppApply_MapsDevicePolicyViolation(t *testing.T) {
682682 assert.Equal(t, "device-policy-violation", envelope.Error)
683683 }
684684
685+func TestHandler_AppDeploy_MapsRuntimeGuardViolation(t *testing.T) {
686+ appSvc := inmocks.NewMockAppService(t)
687+ handler := appsTestHandler(t, appSvc)
688+
689+ appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(
690+ nil,
691+ fmt.Errorf("deployment: create container for service %q: %w: bind %q overlaps a protected Gordon path", "web", domain.ErrRuntimeGuard, "conf"),
692+ ).Once()
693+
694+ rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/blog/deploy",
695+ dto.AppDeployRequest{}, "admin:apps:write")
696+ assert.Equal(t, http.StatusBadRequest, rec.Code)
697+ var envelope dto.AppError
698+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
699+ assert.Equal(t, "runtime-guard-violation", envelope.Error)
700+}
701+
685702 func TestHandler_AppApply_MapsRuntimeUnsupported(t *testing.T) {
686703 appSvc := inmocks.NewMockAppService(t)
687704 handler := appsTestHandler(t, appSvc)
@@ -682,6 +682,23 @@ func TestHandler_AppApply_MapsDevicePolicyViolation(t *testing.T) {
682 assert.Equal(t, "device-policy-violation", envelope.Error)682 assert.Equal(t, "device-policy-violation", envelope.Error)
683 }683 }
684 684
685+func TestHandler_AppDeploy_MapsRuntimeGuardViolation(t *testing.T) {
686+ appSvc := inmocks.NewMockAppService(t)
687+ handler := appsTestHandler(t, appSvc)
688+
689+ appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(
690+ nil,
691+ fmt.Errorf("deployment: create container for service %q: %w: bind %q overlaps a protected Gordon path", "web", domain.ErrRuntimeGuard, "conf"),
692+ ).Once()
693+
694+ rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/blog/deploy",
695+ dto.AppDeployRequest{}, "admin:apps:write")
696+ assert.Equal(t, http.StatusBadRequest, rec.Code)
697+ var envelope dto.AppError
698+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
699+ assert.Equal(t, "runtime-guard-violation", envelope.Error)
700+}
701+
685 func TestHandler_AppApply_MapsRuntimeUnsupported(t *testing.T) {702 func TestHandler_AppApply_MapsRuntimeUnsupported(t *testing.T) {
686 appSvc := inmocks.NewMockAppService(t)703 appSvc := inmocks.NewMockAppService(t)
687 handler := appsTestHandler(t, appSvc)704 handler := appsTestHandler(t, appSvc)
M internal/adapters/out/secretfiles/store.go +5 -1
@@ -38,7 +38,11 @@ var _ out.SecretFileStore = (*Store)(nil)
3838
3939 // NewStore opens (creating if needed) <dataDir>/secrets-run.
4040 func NewStore(dataDir string) (*Store, error) {
41- dir := filepath.Join(dataDir, "secrets-run")
41+ // Bind sources must be absolute: resolve a relative data dir once.
42+ dir, err := filepath.Abs(filepath.Join(dataDir, "secrets-run"))
43+ if err != nil {
44+ return nil, fmt.Errorf("secret files: resolve base directory: %w", err)
45+ }
4246 if err := os.MkdirAll(dir, dirMode); err != nil {
4347 return nil, fmt.Errorf("secret files: create base directory: %w", err)
4448 }
@@ -38,7 +38,11 @@ var _ out.SecretFileStore = (*Store)(nil)
38 38
39 // NewStore opens (creating if needed) <dataDir>/secrets-run.39 // NewStore opens (creating if needed) <dataDir>/secrets-run.
40 func NewStore(dataDir string) (*Store, error) {40 func NewStore(dataDir string) (*Store, error) {
41- dir := filepath.Join(dataDir, "secrets-run")41+ // Bind sources must be absolute: resolve a relative data dir once.
42+ dir, err := filepath.Abs(filepath.Join(dataDir, "secrets-run"))
43+ if err != nil {
44+ return nil, fmt.Errorf("secret files: resolve base directory: %w", err)
45+ }
42 if err := os.MkdirAll(dir, dirMode); err != nil {46 if err := os.MkdirAll(dir, dirMode); err != nil {
43 return nil, fmt.Errorf("secret files: create base directory: %w", err)47 return nil, fmt.Errorf("secret files: create base directory: %w", err)
44 }48 }
M internal/app/app_wiring.go +11 -0
@@ -88,6 +88,10 @@ func (si *serviceInit) initApps() error {
8888 if err != nil {
8989 return err
9090 }
91+ secretFiles, guard, err := buildAppRuntimeSafety(si, dataDir)
92+ if err != nil {
93+ return si.log.WrapErr(err, "failed to prepare app runtime safety")
94+ }
9195 si.svc.appDeploySvc = deployment.NewService(deployment.Deps{
9296 State: store,
9397 Runtime: si.svc.runtime,
@@ -106,7 +110,14 @@ func (si *serviceInit) initApps() error {
106110 Limits: limits,
107111 ImagePolicy: imagePolicy,
108112 Traffic: si.svc.appTrafficPublisher,
113+ SecretFiles: secretFiles,
114+ Guard: guard,
109115 }, si.log).WithGCBarrier(si.svc.gcBarrier)
116+ // createServices runs before any listener starts, so no deploy can
117+ // race this sweep of secret files left by crashes or downgrades.
118+ if err := si.svc.appDeploySvc.SweepSecretFiles(si.ctx); err != nil {
119+ si.log.Warn().Err(err).Msg("failed to sweep orphan secret files")
120+ }
110121 si.svc.appActivator = apptraffic.NewActivator(si.log)
111122 mountPolicies, err := buildAppMountPolicies(si.cfg)
112123 if err != nil {
@@ -88,6 +88,10 @@ func (si *serviceInit) initApps() error {
88 if err != nil {88 if err != nil {
89 return err89 return err
90 }90 }
91+ secretFiles, guard, err := buildAppRuntimeSafety(si, dataDir)
92+ if err != nil {
93+ return si.log.WrapErr(err, "failed to prepare app runtime safety")
94+ }
91 si.svc.appDeploySvc = deployment.NewService(deployment.Deps{95 si.svc.appDeploySvc = deployment.NewService(deployment.Deps{
92 State: store,96 State: store,
93 Runtime: si.svc.runtime,97 Runtime: si.svc.runtime,
@@ -106,7 +110,14 @@ func (si *serviceInit) initApps() error {
106 Limits: limits,110 Limits: limits,
107 ImagePolicy: imagePolicy,111 ImagePolicy: imagePolicy,
108 Traffic: si.svc.appTrafficPublisher,112 Traffic: si.svc.appTrafficPublisher,
113+ SecretFiles: secretFiles,
114+ Guard: guard,
109 }, si.log).WithGCBarrier(si.svc.gcBarrier)115 }, si.log).WithGCBarrier(si.svc.gcBarrier)
116+ // createServices runs before any listener starts, so no deploy can
117+ // race this sweep of secret files left by crashes or downgrades.
118+ if err := si.svc.appDeploySvc.SweepSecretFiles(si.ctx); err != nil {
119+ si.log.Warn().Err(err).Msg("failed to sweep orphan secret files")
120+ }
110 si.svc.appActivator = apptraffic.NewActivator(si.log)121 si.svc.appActivator = apptraffic.NewActivator(si.log)
111 mountPolicies, err := buildAppMountPolicies(si.cfg)122 mountPolicies, err := buildAppMountPolicies(si.cfg)
112 if err != nil {123 if err != nil {
A internal/app/dumpable_linux.go +12 -0
@@ -0,0 +1,12 @@
1+//go:build linux
2+
3+package app
4+
5+import "golang.org/x/sys/unix"
6+
7+// disableDumpable marks the daemon non-dumpable: no core dumps, and other
8+// processes of the same UID can no longer read /proc/<pid>/environ, mem,
9+// or fds, nor ptrace it. Root keeps that access.
10+func disableDumpable() error {
11+ return unix.Prctl(unix.PR_SET_DUMPABLE, 0, 0, 0, 0)
12+}
@@ -0,0 +1,12 @@
1+//go:build linux
2+
3+package app
4+
5+import "golang.org/x/sys/unix"
6+
7+// disableDumpable marks the daemon non-dumpable: no core dumps, and other
8+// processes of the same UID can no longer read /proc/<pid>/environ, mem,
9+// or fds, nor ptrace it. Root keeps that access.
10+func disableDumpable() error {
11+ return unix.Prctl(unix.PR_SET_DUMPABLE, 0, 0, 0, 0)
12+}
A internal/app/dumpable_linux_test.go +34 -0
@@ -0,0 +1,34 @@
1+//go:build linux
2+
3+package app
4+
5+import (
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+ "golang.org/x/sys/unix"
14+)
15+
16+func TestDisableDumpable(t *testing.T) {
17+ t.Cleanup(func() { _ = unix.Prctl(unix.PR_SET_DUMPABLE, 1, 0, 0, 0) })
18+
19+ require.NoError(t, disableDumpable())
20+ got, err := unix.PrctlRetInt(unix.PR_GET_DUMPABLE, 0, 0, 0, 0)
21+ require.NoError(t, err)
22+ assert.Equal(t, 0, got)
23+
24+ // readFileBeneath reads secrets through /proc/self/fd/N; the kernel
25+ // keeps that self access for a non-dumpable process.
26+ path := filepath.Join(t.TempDir(), "secret")
27+ require.NoError(t, os.WriteFile(path, []byte("value"), 0o600))
28+ fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC, 0)
29+ require.NoError(t, err)
30+ defer unix.Close(fd)
31+ data, err := os.ReadFile(fmt.Sprintf("/proc/self/fd/%d", fd))
32+ require.NoError(t, err)
33+ assert.Equal(t, "value", string(data))
34+}
@@ -0,0 +1,34 @@
1+//go:build linux
2+
3+package app
4+
5+import (
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+ "golang.org/x/sys/unix"
14+)
15+
16+func TestDisableDumpable(t *testing.T) {
17+ t.Cleanup(func() { _ = unix.Prctl(unix.PR_SET_DUMPABLE, 1, 0, 0, 0) })
18+
19+ require.NoError(t, disableDumpable())
20+ got, err := unix.PrctlRetInt(unix.PR_GET_DUMPABLE, 0, 0, 0, 0)
21+ require.NoError(t, err)
22+ assert.Equal(t, 0, got)
23+
24+ // readFileBeneath reads secrets through /proc/self/fd/N; the kernel
25+ // keeps that self access for a non-dumpable process.
26+ path := filepath.Join(t.TempDir(), "secret")
27+ require.NoError(t, os.WriteFile(path, []byte("value"), 0o600))
28+ fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC, 0)
29+ require.NoError(t, err)
30+ defer unix.Close(fd)
31+ data, err := os.ReadFile(fmt.Sprintf("/proc/self/fd/%d", fd))
32+ require.NoError(t, err)
33+ assert.Equal(t, "value", string(data))
34+}
A internal/app/dumpable_other.go +6 -0
@@ -0,0 +1,6 @@
1+//go:build !linux
2+
3+package app
4+
5+// disableDumpable is Linux-only; other platforms keep their defaults.
6+func disableDumpable() error { return nil }
@@ -0,0 +1,6 @@
1+//go:build !linux
2+
3+package app
4+
5+// disableDumpable is Linux-only; other platforms keep their defaults.
6+func disableDumpable() error { return nil }
M internal/app/run.go +6 -0
@@ -32,6 +32,12 @@ import (
3232
3333 // Run initializes and starts the Gordon application.
3434 func Run(ctx context.Context, configPath string) error {
35+ // Before any secret is loaded: keep same-UID processes out of this
36+ // process's /proc entries (environ, mem, fds) and disable core dumps.
37+ if err := disableDumpable(); err != nil {
38+ return fmt.Errorf("failed to disable process dumping: %w", err)
39+ }
40+
3541 // Load configuration
3642 v, cfg, err := initConfig(configPath)
3743 if err != nil {
@@ -32,6 +32,12 @@ import (
32 32
33 // Run initializes and starts the Gordon application.33 // Run initializes and starts the Gordon application.
34 func Run(ctx context.Context, configPath string) error {34 func Run(ctx context.Context, configPath string) error {
35+ // Before any secret is loaded: keep same-UID processes out of this
36+ // process's /proc entries (environ, mem, fds) and disable core dumps.
37+ if err := disableDumpable(); err != nil {
38+ return fmt.Errorf("failed to disable process dumping: %w", err)
39+ }
40+
35 // Load configuration41 // Load configuration
36 v, cfg, err := initConfig(configPath)42 v, cfg, err := initConfig(configPath)
37 if err != nil {43 if err != nil {
A internal/app/runtime_guard.go +96 -0
@@ -0,0 +1,96 @@
1+package app
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "slices"
7+ "strings"
8+
9+ "github.com/bnema/gordon/internal/adapters/out/secretfiles"
10+ "github.com/bnema/gordon/internal/boundaries/out"
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+// protectedRuntimePaths lists the host paths no app bind may equal, lie
15+// under, or contain: Gordon's data and config, runtime sockets, Gordon's
16+// runtime credentials, and key stores. Empty entries are skipped; each
17+// entry is cleaned and its symlink-resolved form is added when it differs.
18+func protectedRuntimePaths(dataDir, configFile, runtimeSocket string, getenv func(string) string, home string) []string {
19+ runtimeDir := getenv("XDG_RUNTIME_DIR")
20+ configHome := getenv("XDG_CONFIG_HOME")
21+ if configHome == "" {
22+ configHome = homePath(home, ".config")
23+ }
24+ dockerHost, _ := strings.CutPrefix(getenv("DOCKER_HOST"), "unix://")
25+ if strings.Contains(dockerHost, "://") {
26+ dockerHost = ""
27+ }
28+ raw := []string{
29+ dataDir,
30+ configFile,
31+ "/var/run/docker.sock",
32+ "/run/docker.sock",
33+ "/run/podman/podman.sock",
34+ subPath(runtimeDir, "docker.sock"),
35+ subPath(runtimeDir, "podman", "podman.sock"),
36+ subPath(runtimeDir, "gordon"),
37+ homePath(home, ".gordon", "run"),
38+ dockerHost,
39+ runtimeSocket,
40+ getenv("GNUPGHOME"),
41+ homePath(home, ".gnupg"),
42+ getenv("PASSWORD_STORE_DIR"),
43+ homePath(home, ".password-store"),
44+ getenv("SOPS_AGE_KEY_FILE"),
45+ subPath(configHome, "sops", "age"),
46+ }
47+ var paths []string
48+ for _, p := range raw {
49+ if p == "" {
50+ continue
51+ }
52+ // A relative data dir or config path is resolved against the
53+ // working directory, as the daemon itself resolves it.
54+ clean, err := filepath.Abs(p)
55+ if err != nil {
56+ continue
57+ }
58+ paths = append(paths, clean)
59+ if resolved, err := filepath.EvalSymlinks(clean); err == nil && resolved != clean {
60+ paths = append(paths, resolved)
61+ }
62+ }
63+ slices.Sort(paths)
64+ return slices.Compact(paths)
65+}
66+
67+// subPath joins elems under base, or returns "" when base is empty.
68+func subPath(base string, elems ...string) string {
69+ if base == "" {
70+ return ""
71+ }
72+ return filepath.Join(append([]string{base}, elems...)...)
73+}
74+
75+// homePath joins elems under the home directory, or returns "" when the
76+// home directory is unknown.
77+func homePath(home string, elems ...string) string {
78+ return subPath(home, elems...)
79+}
80+
81+// buildAppRuntimeSafety opens the host secret file store and builds the
82+// runtime guard applied to every app container create.
83+func buildAppRuntimeSafety(si *serviceInit, dataDir string) (out.SecretFileStore, domain.RuntimeGuard, error) {
84+ store, err := secretfiles.NewStore(dataDir)
85+ if err != nil {
86+ return nil, domain.RuntimeGuard{}, err
87+ }
88+ home, _ := os.UserHomeDir()
89+ var configFile, runtimeSocket string
90+ if si.v != nil {
91+ configFile = si.v.ConfigFileUsed()
92+ runtimeSocket = resolveRuntimeConfig(si.v.GetString("server.runtime"))
93+ }
94+ paths := protectedRuntimePaths(dataDir, configFile, runtimeSocket, os.Getenv, home)
95+ return store, domain.RuntimeGuard{ProtectedPaths: paths, SecretFilesDir: store.Dir()}, nil
96+}
@@ -0,0 +1,96 @@
1+package app
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "slices"
7+ "strings"
8+
9+ "github.com/bnema/gordon/internal/adapters/out/secretfiles"
10+ "github.com/bnema/gordon/internal/boundaries/out"
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+// protectedRuntimePaths lists the host paths no app bind may equal, lie
15+// under, or contain: Gordon's data and config, runtime sockets, Gordon's
16+// runtime credentials, and key stores. Empty entries are skipped; each
17+// entry is cleaned and its symlink-resolved form is added when it differs.
18+func protectedRuntimePaths(dataDir, configFile, runtimeSocket string, getenv func(string) string, home string) []string {
19+ runtimeDir := getenv("XDG_RUNTIME_DIR")
20+ configHome := getenv("XDG_CONFIG_HOME")
21+ if configHome == "" {
22+ configHome = homePath(home, ".config")
23+ }
24+ dockerHost, _ := strings.CutPrefix(getenv("DOCKER_HOST"), "unix://")
25+ if strings.Contains(dockerHost, "://") {
26+ dockerHost = ""
27+ }
28+ raw := []string{
29+ dataDir,
30+ configFile,
31+ "/var/run/docker.sock",
32+ "/run/docker.sock",
33+ "/run/podman/podman.sock",
34+ subPath(runtimeDir, "docker.sock"),
35+ subPath(runtimeDir, "podman", "podman.sock"),
36+ subPath(runtimeDir, "gordon"),
37+ homePath(home, ".gordon", "run"),
38+ dockerHost,
39+ runtimeSocket,
40+ getenv("GNUPGHOME"),
41+ homePath(home, ".gnupg"),
42+ getenv("PASSWORD_STORE_DIR"),
43+ homePath(home, ".password-store"),
44+ getenv("SOPS_AGE_KEY_FILE"),
45+ subPath(configHome, "sops", "age"),
46+ }
47+ var paths []string
48+ for _, p := range raw {
49+ if p == "" {
50+ continue
51+ }
52+ // A relative data dir or config path is resolved against the
53+ // working directory, as the daemon itself resolves it.
54+ clean, err := filepath.Abs(p)
55+ if err != nil {
56+ continue
57+ }
58+ paths = append(paths, clean)
59+ if resolved, err := filepath.EvalSymlinks(clean); err == nil && resolved != clean {
60+ paths = append(paths, resolved)
61+ }
62+ }
63+ slices.Sort(paths)
64+ return slices.Compact(paths)
65+}
66+
67+// subPath joins elems under base, or returns "" when base is empty.
68+func subPath(base string, elems ...string) string {
69+ if base == "" {
70+ return ""
71+ }
72+ return filepath.Join(append([]string{base}, elems...)...)
73+}
74+
75+// homePath joins elems under the home directory, or returns "" when the
76+// home directory is unknown.
77+func homePath(home string, elems ...string) string {
78+ return subPath(home, elems...)
79+}
80+
81+// buildAppRuntimeSafety opens the host secret file store and builds the
82+// runtime guard applied to every app container create.
83+func buildAppRuntimeSafety(si *serviceInit, dataDir string) (out.SecretFileStore, domain.RuntimeGuard, error) {
84+ store, err := secretfiles.NewStore(dataDir)
85+ if err != nil {
86+ return nil, domain.RuntimeGuard{}, err
87+ }
88+ home, _ := os.UserHomeDir()
89+ var configFile, runtimeSocket string
90+ if si.v != nil {
91+ configFile = si.v.ConfigFileUsed()
92+ runtimeSocket = resolveRuntimeConfig(si.v.GetString("server.runtime"))
93+ }
94+ paths := protectedRuntimePaths(dataDir, configFile, runtimeSocket, os.Getenv, home)
95+ return store, domain.RuntimeGuard{ProtectedPaths: paths, SecretFilesDir: store.Dir()}, nil
96+}
A internal/app/runtime_guard_test.go +84 -0
@@ -0,0 +1,84 @@
1+package app
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+func fakeEnv(values map[string]string) func(string) string {
15+ return func(key string) string { return values[key] }
16+}
17+
18+func TestProtectedRuntimePaths_AllSources(t *testing.T) {
19+ home := t.TempDir()
20+ env := fakeEnv(map[string]string{
21+ "XDG_RUNTIME_DIR": "/run/user/1000",
22+ "DOCKER_HOST": "unix:///x/d.sock",
23+ "GNUPGHOME": "/keys/gnupg",
24+ "PASSWORD_STORE_DIR": "/keys/pass",
25+ "SOPS_AGE_KEY_FILE": "/keys/age.txt",
26+ "XDG_CONFIG_HOME": "/cfg",
27+ })
28+ got := protectedRuntimePaths("/data/gordon", "/etc/gordon/gordon.toml", "/custom/podman.sock", env, home)
29+
30+ for _, want := range []string{
31+ "/data/gordon",
32+ "/etc/gordon/gordon.toml",
33+ "/var/run/docker.sock",
34+ "/run/docker.sock",
35+ "/run/podman/podman.sock",
36+ "/run/user/1000/docker.sock",
37+ "/run/user/1000/podman/podman.sock",
38+ "/run/user/1000/gordon",
39+ filepath.Join(home, ".gordon", "run"),
40+ "/x/d.sock",
41+ "/custom/podman.sock",
42+ "/keys/gnupg",
43+ filepath.Join(home, ".gnupg"),
44+ "/keys/pass",
45+ filepath.Join(home, ".password-store"),
46+ "/keys/age.txt",
47+ "/cfg/sops/age",
48+ } {
49+ assert.Contains(t, got, want)
50+ }
51+ assert.IsIncreasing(t, got)
52+}
53+
54+func TestProtectedRuntimePaths_SkipsEmptyAndTCP(t *testing.T) {
55+ got := protectedRuntimePaths("/data", "", "", fakeEnv(map[string]string{"DOCKER_HOST": "tcp://h:2375"}), "")
56+ assert.Equal(t, []string{"/data", "/run/docker.sock", "/run/podman/podman.sock", "/var/run/docker.sock"}, got)
57+}
58+
59+func TestProtectedRuntimePaths_SymlinkAddsBothForms(t *testing.T) {
60+ base := t.TempDir()
61+ real := filepath.Join(base, "real")
62+ require.NoError(t, os.Mkdir(real, 0o700))
63+ link := filepath.Join(base, "link")
64+ require.NoError(t, os.Symlink(real, link))
65+ realResolved, err := filepath.EvalSymlinks(real)
66+ require.NoError(t, err)
67+
68+ got := protectedRuntimePaths(link, "", "", fakeEnv(nil), "")
69+ assert.Contains(t, got, link)
70+ assert.Contains(t, got, realResolved)
71+}
72+
73+func TestBuildAppRuntimeSafety(t *testing.T) {
74+ dataDir := t.TempDir()
75+ store, guard, err := buildAppRuntimeSafety(&serviceInit{}, dataDir)
76+ require.NoError(t, err)
77+ require.NotNil(t, store)
78+ assert.Equal(t, filepath.Join(dataDir, "secrets-run"), guard.SecretFilesDir)
79+ assert.Contains(t, guard.ProtectedPaths, dataDir)
80+
81+ // The data dir itself cannot be bound into an app.
82+ err = guard.Check(&domain.ContainerConfig{Binds: []domain.ContainerBind{{Name: "all", Source: dataDir, Destination: "/data"}}})
83+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
84+}
@@ -0,0 +1,84 @@
1+package app
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+func fakeEnv(values map[string]string) func(string) string {
15+ return func(key string) string { return values[key] }
16+}
17+
18+func TestProtectedRuntimePaths_AllSources(t *testing.T) {
19+ home := t.TempDir()
20+ env := fakeEnv(map[string]string{
21+ "XDG_RUNTIME_DIR": "/run/user/1000",
22+ "DOCKER_HOST": "unix:///x/d.sock",
23+ "GNUPGHOME": "/keys/gnupg",
24+ "PASSWORD_STORE_DIR": "/keys/pass",
25+ "SOPS_AGE_KEY_FILE": "/keys/age.txt",
26+ "XDG_CONFIG_HOME": "/cfg",
27+ })
28+ got := protectedRuntimePaths("/data/gordon", "/etc/gordon/gordon.toml", "/custom/podman.sock", env, home)
29+
30+ for _, want := range []string{
31+ "/data/gordon",
32+ "/etc/gordon/gordon.toml",
33+ "/var/run/docker.sock",
34+ "/run/docker.sock",
35+ "/run/podman/podman.sock",
36+ "/run/user/1000/docker.sock",
37+ "/run/user/1000/podman/podman.sock",
38+ "/run/user/1000/gordon",
39+ filepath.Join(home, ".gordon", "run"),
40+ "/x/d.sock",
41+ "/custom/podman.sock",
42+ "/keys/gnupg",
43+ filepath.Join(home, ".gnupg"),
44+ "/keys/pass",
45+ filepath.Join(home, ".password-store"),
46+ "/keys/age.txt",
47+ "/cfg/sops/age",
48+ } {
49+ assert.Contains(t, got, want)
50+ }
51+ assert.IsIncreasing(t, got)
52+}
53+
54+func TestProtectedRuntimePaths_SkipsEmptyAndTCP(t *testing.T) {
55+ got := protectedRuntimePaths("/data", "", "", fakeEnv(map[string]string{"DOCKER_HOST": "tcp://h:2375"}), "")
56+ assert.Equal(t, []string{"/data", "/run/docker.sock", "/run/podman/podman.sock", "/var/run/docker.sock"}, got)
57+}
58+
59+func TestProtectedRuntimePaths_SymlinkAddsBothForms(t *testing.T) {
60+ base := t.TempDir()
61+ real := filepath.Join(base, "real")
62+ require.NoError(t, os.Mkdir(real, 0o700))
63+ link := filepath.Join(base, "link")
64+ require.NoError(t, os.Symlink(real, link))
65+ realResolved, err := filepath.EvalSymlinks(real)
66+ require.NoError(t, err)
67+
68+ got := protectedRuntimePaths(link, "", "", fakeEnv(nil), "")
69+ assert.Contains(t, got, link)
70+ assert.Contains(t, got, realResolved)
71+}
72+
73+func TestBuildAppRuntimeSafety(t *testing.T) {
74+ dataDir := t.TempDir()
75+ store, guard, err := buildAppRuntimeSafety(&serviceInit{}, dataDir)
76+ require.NoError(t, err)
77+ require.NotNil(t, store)
78+ assert.Equal(t, filepath.Join(dataDir, "secrets-run"), guard.SecretFilesDir)
79+ assert.Contains(t, guard.ProtectedPaths, dataDir)
80+
81+ // The data dir itself cannot be bound into an app.
82+ err = guard.Check(&domain.ContainerConfig{Binds: []domain.ContainerBind{{Name: "all", Source: dataDir, Destination: "/data"}}})
83+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
84+}
M internal/domain/errors.go +4 -0
@@ -83,6 +83,10 @@ var (
8383 // distinct from ErrInvalidAppSpec: the manifest may be valid while the
8484 // installation policy refuses to serve it.
8585 ErrDevicePolicy = errors.New("device policy violation")
86+ // ErrRuntimeGuard wraps app container configs refused by the runtime
87+ // guard: host or shared namespaces, host paths passed as volume
88+ // names, or binds overlapping Gordon's own sensitive paths.
89+ ErrRuntimeGuard = errors.New("runtime guard violation")
8690
8791 // App state errors
8892 ErrAppStateIO = errors.New("app state storage failure")
@@ -83,6 +83,10 @@ var (
83 // distinct from ErrInvalidAppSpec: the manifest may be valid while the83 // distinct from ErrInvalidAppSpec: the manifest may be valid while the
84 // installation policy refuses to serve it.84 // installation policy refuses to serve it.
85 ErrDevicePolicy = errors.New("device policy violation")85 ErrDevicePolicy = errors.New("device policy violation")
86+ // ErrRuntimeGuard wraps app container configs refused by the runtime
87+ // guard: host or shared namespaces, host paths passed as volume
88+ // names, or binds overlapping Gordon's own sensitive paths.
89+ ErrRuntimeGuard = errors.New("runtime guard violation")
86 90
87 // App state errors91 // App state errors
88 ErrAppStateIO = errors.New("app state storage failure")92 ErrAppStateIO = errors.New("app state storage failure")
A internal/domain/runtime_guard.go +114 -0
@@ -0,0 +1,114 @@
1+package domain
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "strings"
7+)
8+
9+// RuntimeGuard is the last check on every app container config before it
10+// reaches the runtime. It refuses configs that would give a workload
11+// access to the host or to Gordon itself, whatever produced them.
12+//
13+// The zero value still refuses host/shared network namespaces and host
14+// paths passed as volume names; ProtectedPaths adds the bind checks.
15+type RuntimeGuard struct {
16+ // ProtectedPaths are absolute host paths no bind may equal, lie under,
17+ // or contain (data dir, config, runtime sockets, key stores).
18+ ProtectedPaths []string
19+ // SecretFilesDir is the host base directory of file-delivered secrets
20+ // (<data_dir>/secrets-run). Gordon's own read-only secret binds under
21+ // it are the only allowed exception.
22+ SecretFilesDir string
23+}
24+
25+// Check refuses a config that breaks the guard. Errors wrap
26+// ErrRuntimeGuard and name the bind, volume destination, or network mode,
27+// never a host path.
28+func (g RuntimeGuard) Check(config *ContainerConfig) error {
29+ if err := checkGuardNetworkMode(config.NetworkMode); err != nil {
30+ return err
31+ }
32+ if err := checkGuardVolumes(config.Volumes); err != nil {
33+ return err
34+ }
35+ if err := checkGuardVolumes(config.ReadOnlyVolumes); err != nil {
36+ return err
37+ }
38+ for _, bind := range config.Binds {
39+ if g.isOwnSecretBind(config, bind) {
40+ continue
41+ }
42+ if g.overlapsProtected(bind.Source) {
43+ return fmt.Errorf("%w: bind %q overlaps a protected Gordon path", ErrRuntimeGuard, bind.Name)
44+ }
45+ }
46+ return nil
47+}
48+
49+func checkGuardNetworkMode(mode string) error {
50+ if mode == "host" || strings.HasPrefix(mode, "container:") || strings.HasPrefix(mode, "ns:") {
51+ return fmt.Errorf("%w: network mode %q is not allowed", ErrRuntimeGuard, guardNetworkLabel(mode))
52+ }
53+ return nil
54+}
55+
56+// guardNetworkLabel keeps the mode kind but drops a namespace path.
57+func guardNetworkLabel(mode string) string {
58+ if kind, _, ok := strings.Cut(mode, ":"); ok {
59+ return kind + ":"
60+ }
61+ return mode
62+}
63+
64+// checkGuardVolumes refuses volume names the runtime would read as host
65+// paths and turn into binds.
66+func checkGuardVolumes(volumes map[string]string) error {
67+ for dest, name := range volumes {
68+ if strings.ContainsAny(name, `/\`) {
69+ return fmt.Errorf("%w: volume for %q is not a named volume", ErrRuntimeGuard, dest)
70+ }
71+ }
72+ return nil
73+}
74+
75+// isOwnSecretBind reports whether bind is exactly the read-only secret file
76+// mount Gordon creates: SecretFilesDir/<app>/<container>/<KEY> mounted at
77+// /run/secrets/<KEY>.
78+func (g RuntimeGuard) isOwnSecretBind(config *ContainerConfig, bind ContainerBind) bool {
79+ if g.SecretFilesDir == "" || !bind.ReadOnly {
80+ return false
81+ }
82+ app := config.Labels[LabelApp]
83+ if app == "" || config.Name == "" {
84+ return false
85+ }
86+ key := filepath.Base(bind.Source)
87+ if err := ValidateEnvKey(key); err != nil {
88+ return false
89+ }
90+ return bind.Source == filepath.Join(g.SecretFilesDir, app, config.Name, key) &&
91+ bind.Destination == SecretFilePath(key)
92+}
93+
94+// overlapsProtected reports whether source, raw or symlink-resolved, is
95+// equal to, under, or an ancestor of a protected path.
96+func (g RuntimeGuard) overlapsProtected(source string) bool {
97+ for _, candidate := range guardCandidates(source) {
98+ for _, protected := range g.ProtectedPaths {
99+ if resolvedUnderRoot(protected, candidate) || resolvedUnderRoot(candidate, protected) {
100+ return true
101+ }
102+ }
103+ }
104+ return false
105+}
106+
107+func guardCandidates(source string) []string {
108+ clean := filepath.Clean(source)
109+ candidates := []string{clean}
110+ if resolved, err := filepath.EvalSymlinks(clean); err == nil && resolved != clean {
111+ candidates = append(candidates, resolved)
112+ }
113+ return candidates
114+}
@@ -0,0 +1,114 @@
1+package domain
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "strings"
7+)
8+
9+// RuntimeGuard is the last check on every app container config before it
10+// reaches the runtime. It refuses configs that would give a workload
11+// access to the host or to Gordon itself, whatever produced them.
12+//
13+// The zero value still refuses host/shared network namespaces and host
14+// paths passed as volume names; ProtectedPaths adds the bind checks.
15+type RuntimeGuard struct {
16+ // ProtectedPaths are absolute host paths no bind may equal, lie under,
17+ // or contain (data dir, config, runtime sockets, key stores).
18+ ProtectedPaths []string
19+ // SecretFilesDir is the host base directory of file-delivered secrets
20+ // (<data_dir>/secrets-run). Gordon's own read-only secret binds under
21+ // it are the only allowed exception.
22+ SecretFilesDir string
23+}
24+
25+// Check refuses a config that breaks the guard. Errors wrap
26+// ErrRuntimeGuard and name the bind, volume destination, or network mode,
27+// never a host path.
28+func (g RuntimeGuard) Check(config *ContainerConfig) error {
29+ if err := checkGuardNetworkMode(config.NetworkMode); err != nil {
30+ return err
31+ }
32+ if err := checkGuardVolumes(config.Volumes); err != nil {
33+ return err
34+ }
35+ if err := checkGuardVolumes(config.ReadOnlyVolumes); err != nil {
36+ return err
37+ }
38+ for _, bind := range config.Binds {
39+ if g.isOwnSecretBind(config, bind) {
40+ continue
41+ }
42+ if g.overlapsProtected(bind.Source) {
43+ return fmt.Errorf("%w: bind %q overlaps a protected Gordon path", ErrRuntimeGuard, bind.Name)
44+ }
45+ }
46+ return nil
47+}
48+
49+func checkGuardNetworkMode(mode string) error {
50+ if mode == "host" || strings.HasPrefix(mode, "container:") || strings.HasPrefix(mode, "ns:") {
51+ return fmt.Errorf("%w: network mode %q is not allowed", ErrRuntimeGuard, guardNetworkLabel(mode))
52+ }
53+ return nil
54+}
55+
56+// guardNetworkLabel keeps the mode kind but drops a namespace path.
57+func guardNetworkLabel(mode string) string {
58+ if kind, _, ok := strings.Cut(mode, ":"); ok {
59+ return kind + ":"
60+ }
61+ return mode
62+}
63+
64+// checkGuardVolumes refuses volume names the runtime would read as host
65+// paths and turn into binds.
66+func checkGuardVolumes(volumes map[string]string) error {
67+ for dest, name := range volumes {
68+ if strings.ContainsAny(name, `/\`) {
69+ return fmt.Errorf("%w: volume for %q is not a named volume", ErrRuntimeGuard, dest)
70+ }
71+ }
72+ return nil
73+}
74+
75+// isOwnSecretBind reports whether bind is exactly the read-only secret file
76+// mount Gordon creates: SecretFilesDir/<app>/<container>/<KEY> mounted at
77+// /run/secrets/<KEY>.
78+func (g RuntimeGuard) isOwnSecretBind(config *ContainerConfig, bind ContainerBind) bool {
79+ if g.SecretFilesDir == "" || !bind.ReadOnly {
80+ return false
81+ }
82+ app := config.Labels[LabelApp]
83+ if app == "" || config.Name == "" {
84+ return false
85+ }
86+ key := filepath.Base(bind.Source)
87+ if err := ValidateEnvKey(key); err != nil {
88+ return false
89+ }
90+ return bind.Source == filepath.Join(g.SecretFilesDir, app, config.Name, key) &&
91+ bind.Destination == SecretFilePath(key)
92+}
93+
94+// overlapsProtected reports whether source, raw or symlink-resolved, is
95+// equal to, under, or an ancestor of a protected path.
96+func (g RuntimeGuard) overlapsProtected(source string) bool {
97+ for _, candidate := range guardCandidates(source) {
98+ for _, protected := range g.ProtectedPaths {
99+ if resolvedUnderRoot(protected, candidate) || resolvedUnderRoot(candidate, protected) {
100+ return true
101+ }
102+ }
103+ }
104+ return false
105+}
106+
107+func guardCandidates(source string) []string {
108+ clean := filepath.Clean(source)
109+ candidates := []string{clean}
110+ if resolved, err := filepath.EvalSymlinks(clean); err == nil && resolved != clean {
111+ candidates = append(candidates, resolved)
112+ }
113+ return candidates
114+}
A internal/domain/runtime_guard_test.go +114 -0
@@ -0,0 +1,114 @@
1+package domain_test
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+const guardContainer = "gordon-blog--web--x"
15+
16+func guardConfig(binds ...domain.ContainerBind) *domain.ContainerConfig {
17+ return &domain.ContainerConfig{
18+ Name: guardContainer,
19+ NetworkMode: "gordon-app-1",
20+ Labels: map[string]string{domain.LabelApp: "blog"},
21+ Binds: binds,
22+ }
23+}
24+
25+func TestRuntimeGuard_NetworkModes(t *testing.T) {
26+ var guard domain.RuntimeGuard
27+ for _, mode := range []string{"host", "container:abc", "ns:/proc/1/ns/net"} {
28+ cfg := guardConfig()
29+ cfg.NetworkMode = mode
30+ err := guard.Check(cfg)
31+ require.ErrorIs(t, err, domain.ErrRuntimeGuard, mode)
32+ assert.NotContains(t, err.Error(), "/proc")
33+ }
34+ require.NoError(t, guard.Check(guardConfig()))
35+}
36+
37+func TestRuntimeGuard_VolumeNames(t *testing.T) {
38+ var guard domain.RuntimeGuard
39+ for _, name := range []string{"/etc", "a/b", `a\b`} {
40+ cfg := guardConfig()
41+ cfg.Volumes = map[string]string{"/data": name}
42+ require.ErrorIs(t, guard.Check(cfg), domain.ErrRuntimeGuard, name)
43+
44+ cfg = guardConfig()
45+ cfg.ReadOnlyVolumes = map[string]string{"/data": name}
46+ require.ErrorIs(t, guard.Check(cfg), domain.ErrRuntimeGuard, name)
47+ }
48+ cfg := guardConfig()
49+ cfg.Volumes = map[string]string{"/data": "gordon-blog--web--vol--data"}
50+ require.NoError(t, guard.Check(cfg))
51+}
52+
53+func TestRuntimeGuard_ProtectedBinds(t *testing.T) {
54+ base := t.TempDir()
55+ dataDir := filepath.Join(base, "data")
56+ protectedChild := filepath.Join(dataDir, "registry")
57+ require.NoError(t, os.MkdirAll(protectedChild, 0o700))
58+ unrelated := filepath.Join(base, "public")
59+ require.NoError(t, os.MkdirAll(unrelated, 0o700))
60+ link := filepath.Join(base, "link")
61+ require.NoError(t, os.Symlink(dataDir, link))
62+
63+ guard := domain.RuntimeGuard{ProtectedPaths: []string{dataDir}}
64+ cases := map[string]string{
65+ "equal": dataDir,
66+ "under": protectedChild,
67+ "ancestor": base,
68+ "symlink": link,
69+ }
70+ for name, source := range cases {
71+ t.Run(name, func(t *testing.T) {
72+ err := guard.Check(guardConfig(domain.ContainerBind{Name: "conf", Source: source, Destination: "/etc/app", ReadOnly: true}))
73+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
74+ assert.Contains(t, err.Error(), `"conf"`)
75+ assert.NotContains(t, err.Error(), base)
76+ })
77+ }
78+ require.NoError(t, guard.Check(guardConfig(domain.ContainerBind{Name: "conf", Source: unrelated, Destination: "/etc/app", ReadOnly: true})))
79+}
80+
81+func TestRuntimeGuard_OwnSecretBindException(t *testing.T) {
82+ dataDir := t.TempDir()
83+ secretDir := filepath.Join(dataDir, "secrets-run")
84+ guard := domain.RuntimeGuard{ProtectedPaths: []string{dataDir}, SecretFilesDir: secretDir}
85+ own := domain.ContainerBind{
86+ Name: "secret-file-DB",
87+ Source: filepath.Join(secretDir, "blog", guardContainer, "DB"),
88+ Destination: "/run/secrets/DB",
89+ ReadOnly: true,
90+ }
91+ require.NoError(t, guard.Check(guardConfig(own)))
92+
93+ cases := map[string]func(*domain.ContainerConfig){
94+ "read-write": func(c *domain.ContainerConfig) { c.Binds[0].ReadOnly = false },
95+ "wrong dest": func(c *domain.ContainerConfig) { c.Binds[0].Destination = "/run/secrets/OTHER" },
96+ "wrong app": func(c *domain.ContainerConfig) { c.Labels[domain.LabelApp] = "other" },
97+ "no app label": func(c *domain.ContainerConfig) { c.Labels = nil },
98+ "wrong name": func(c *domain.ContainerConfig) { c.Name = "gordon-blog--web--y" },
99+ "extra segment": func(c *domain.ContainerConfig) {
100+ c.Binds[0].Source = filepath.Join(secretDir, "blog", guardContainer, "sub", "DB")
101+ },
102+ "directory bind": func(c *domain.ContainerConfig) { c.Binds[0].Source = filepath.Join(secretDir, "blog", guardContainer) },
103+ "whole base": func(c *domain.ContainerConfig) { c.Binds[0].Source = secretDir },
104+ }
105+ for name, mutate := range cases {
106+ t.Run(name, func(t *testing.T) {
107+ cfg := guardConfig(own)
108+ mutate(cfg)
109+ err := guard.Check(cfg)
110+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
111+ assert.NotContains(t, err.Error(), dataDir)
112+ })
113+ }
114+}
@@ -0,0 +1,114 @@
1+package domain_test
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+const guardContainer = "gordon-blog--web--x"
15+
16+func guardConfig(binds ...domain.ContainerBind) *domain.ContainerConfig {
17+ return &domain.ContainerConfig{
18+ Name: guardContainer,
19+ NetworkMode: "gordon-app-1",
20+ Labels: map[string]string{domain.LabelApp: "blog"},
21+ Binds: binds,
22+ }
23+}
24+
25+func TestRuntimeGuard_NetworkModes(t *testing.T) {
26+ var guard domain.RuntimeGuard
27+ for _, mode := range []string{"host", "container:abc", "ns:/proc/1/ns/net"} {
28+ cfg := guardConfig()
29+ cfg.NetworkMode = mode
30+ err := guard.Check(cfg)
31+ require.ErrorIs(t, err, domain.ErrRuntimeGuard, mode)
32+ assert.NotContains(t, err.Error(), "/proc")
33+ }
34+ require.NoError(t, guard.Check(guardConfig()))
35+}
36+
37+func TestRuntimeGuard_VolumeNames(t *testing.T) {
38+ var guard domain.RuntimeGuard
39+ for _, name := range []string{"/etc", "a/b", `a\b`} {
40+ cfg := guardConfig()
41+ cfg.Volumes = map[string]string{"/data": name}
42+ require.ErrorIs(t, guard.Check(cfg), domain.ErrRuntimeGuard, name)
43+
44+ cfg = guardConfig()
45+ cfg.ReadOnlyVolumes = map[string]string{"/data": name}
46+ require.ErrorIs(t, guard.Check(cfg), domain.ErrRuntimeGuard, name)
47+ }
48+ cfg := guardConfig()
49+ cfg.Volumes = map[string]string{"/data": "gordon-blog--web--vol--data"}
50+ require.NoError(t, guard.Check(cfg))
51+}
52+
53+func TestRuntimeGuard_ProtectedBinds(t *testing.T) {
54+ base := t.TempDir()
55+ dataDir := filepath.Join(base, "data")
56+ protectedChild := filepath.Join(dataDir, "registry")
57+ require.NoError(t, os.MkdirAll(protectedChild, 0o700))
58+ unrelated := filepath.Join(base, "public")
59+ require.NoError(t, os.MkdirAll(unrelated, 0o700))
60+ link := filepath.Join(base, "link")
61+ require.NoError(t, os.Symlink(dataDir, link))
62+
63+ guard := domain.RuntimeGuard{ProtectedPaths: []string{dataDir}}
64+ cases := map[string]string{
65+ "equal": dataDir,
66+ "under": protectedChild,
67+ "ancestor": base,
68+ "symlink": link,
69+ }
70+ for name, source := range cases {
71+ t.Run(name, func(t *testing.T) {
72+ err := guard.Check(guardConfig(domain.ContainerBind{Name: "conf", Source: source, Destination: "/etc/app", ReadOnly: true}))
73+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
74+ assert.Contains(t, err.Error(), `"conf"`)
75+ assert.NotContains(t, err.Error(), base)
76+ })
77+ }
78+ require.NoError(t, guard.Check(guardConfig(domain.ContainerBind{Name: "conf", Source: unrelated, Destination: "/etc/app", ReadOnly: true})))
79+}
80+
81+func TestRuntimeGuard_OwnSecretBindException(t *testing.T) {
82+ dataDir := t.TempDir()
83+ secretDir := filepath.Join(dataDir, "secrets-run")
84+ guard := domain.RuntimeGuard{ProtectedPaths: []string{dataDir}, SecretFilesDir: secretDir}
85+ own := domain.ContainerBind{
86+ Name: "secret-file-DB",
87+ Source: filepath.Join(secretDir, "blog", guardContainer, "DB"),
88+ Destination: "/run/secrets/DB",
89+ ReadOnly: true,
90+ }
91+ require.NoError(t, guard.Check(guardConfig(own)))
92+
93+ cases := map[string]func(*domain.ContainerConfig){
94+ "read-write": func(c *domain.ContainerConfig) { c.Binds[0].ReadOnly = false },
95+ "wrong dest": func(c *domain.ContainerConfig) { c.Binds[0].Destination = "/run/secrets/OTHER" },
96+ "wrong app": func(c *domain.ContainerConfig) { c.Labels[domain.LabelApp] = "other" },
97+ "no app label": func(c *domain.ContainerConfig) { c.Labels = nil },
98+ "wrong name": func(c *domain.ContainerConfig) { c.Name = "gordon-blog--web--y" },
99+ "extra segment": func(c *domain.ContainerConfig) {
100+ c.Binds[0].Source = filepath.Join(secretDir, "blog", guardContainer, "sub", "DB")
101+ },
102+ "directory bind": func(c *domain.ContainerConfig) { c.Binds[0].Source = filepath.Join(secretDir, "blog", guardContainer) },
103+ "whole base": func(c *domain.ContainerConfig) { c.Binds[0].Source = secretDir },
104+ }
105+ for name, mutate := range cases {
106+ t.Run(name, func(t *testing.T) {
107+ cfg := guardConfig(own)
108+ mutate(cfg)
109+ err := guard.Check(cfg)
110+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
111+ assert.NotContains(t, err.Error(), dataDir)
112+ })
113+ }
114+}
M internal/usecase/deployment/candidate.go +3 -0
@@ -199,6 +199,9 @@ func (s *Service) createWithSecretFiles(ctx context.Context, app, service string
199199 }
200200
201201 func (s *Service) createContainer(ctx context.Context, service string, config *domain.ContainerConfig) (*domain.Container, error) {
202+ if err := s.deps.Guard.Check(config); err != nil {
203+ return nil, fmt.Errorf("deployment: create container for service %q: %w", service, err)
204+ }
202205 created, err := s.deps.Runtime.CreateContainer(ctx, config)
203206 if err == nil {
204207 return created, nil
@@ -199,6 +199,9 @@ func (s *Service) createWithSecretFiles(ctx context.Context, app, service string
199 }199 }
200 200
201 func (s *Service) createContainer(ctx context.Context, service string, config *domain.ContainerConfig) (*domain.Container, error) {201 func (s *Service) createContainer(ctx context.Context, service string, config *domain.ContainerConfig) (*domain.Container, error) {
202+ if err := s.deps.Guard.Check(config); err != nil {
203+ return nil, fmt.Errorf("deployment: create container for service %q: %w", service, err)
204+ }
202 created, err := s.deps.Runtime.CreateContainer(ctx, config)205 created, err := s.deps.Runtime.CreateContainer(ctx, config)
203 if err == nil {206 if err == nil {
204 return created, nil207 return created, nil
M internal/usecase/deployment/execute.go +1 -1
@@ -591,7 +591,7 @@ func (s *Service) deployService(ctx context.Context, app, revision string, p pin
591591 // Revalidate the current authorization and source before withdrawing or
592592 // retiring the serving generation. A reload between preflight and this
593593 // service step must fail without mutating the existing workload.
594- if _, err := s.resolveServiceBinds(app, p.spec); err != nil {
594+ if _, err := s.resolveGuardedBinds(app, p.spec); err != nil {
595595 return s.failResult(revision, before, "", err)
596596 }
597597 if _, err := s.resolveServiceDevices(app, p.spec); err != nil {
@@ -591,7 +591,7 @@ func (s *Service) deployService(ctx context.Context, app, revision string, p pin
591 // Revalidate the current authorization and source before withdrawing or591 // Revalidate the current authorization and source before withdrawing or
592 // retiring the serving generation. A reload between preflight and this592 // retiring the serving generation. A reload between preflight and this
593 // service step must fail without mutating the existing workload.593 // service step must fail without mutating the existing workload.
594- if _, err := s.resolveServiceBinds(app, p.spec); err != nil {594+ if _, err := s.resolveGuardedBinds(app, p.spec); err != nil {
595 return s.failResult(revision, before, "", err)595 return s.failResult(revision, before, "", err)
596 }596 }
597 if _, err := s.resolveServiceDevices(app, p.spec); err != nil {597 if _, err := s.resolveServiceDevices(app, p.spec); err != nil {
A internal/usecase/deployment/runtime_guard_internal_test.go +73 -0
@@ -0,0 +1,73 @@
1+package deployment
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/bnema/zerowrap"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
15+ "github.com/bnema/gordon/internal/domain"
16+)
17+
18+func TestCreateContainer_GuardRefusesHostNetwork(t *testing.T) {
19+ runtime := outmocks.NewMockContainerRuntime(t)
20+ svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
21+
22+ _, err := svc.createContainer(context.Background(), "web", &domain.ContainerConfig{Name: "c", NetworkMode: "host"})
23+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
24+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
25+}
26+
27+func TestCreateContainer_GuardRefusesProtectedBind(t *testing.T) {
28+ protected := t.TempDir()
29+ child := filepath.Join(protected, "registry")
30+ require.NoError(t, os.MkdirAll(child, 0o700))
31+ runtime := outmocks.NewMockContainerRuntime(t)
32+ svc := NewService(Deps{Runtime: runtime, Guard: domain.RuntimeGuard{ProtectedPaths: []string{protected}}}, zerowrap.Default())
33+
34+ _, err := svc.createContainer(context.Background(), "web", &domain.ContainerConfig{
35+ Name: "c",
36+ Binds: []domain.ContainerBind{{Name: "conf", Source: child, Destination: "/etc/app", ReadOnly: true}},
37+ })
38+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
39+ assert.NotContains(t, err.Error(), protected)
40+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
41+}
42+
43+func TestCreateWithSecretFiles_GuardAllowsOwnSecretBind(t *testing.T) {
44+ dataDir := t.TempDir()
45+ base := filepath.Join(dataDir, "secrets-run")
46+ store := outmocks.NewMockSecretFileStore(t)
47+ runtime := outmocks.NewMockContainerRuntime(t)
48+ guard := domain.RuntimeGuard{ProtectedPaths: []string{dataDir}, SecretFilesDir: base}
49+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store, Guard: guard}, zerowrap.Default())
50+ config := &domain.ContainerConfig{Name: "gordon-blog--web--abc", Labels: appLabels("blog", "web", "rev-1")}
51+
52+ store.EXPECT().Write(mock.Anything, "blog", "gordon-blog--web--abc", mock.Anything).
53+ Return(map[string]string{"B": filepath.Join(base, "blog", "gordon-blog--web--abc", "B")}, nil).Once()
54+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-1"}, nil).Once()
55+
56+ _, err := svc.createWithSecretFiles(context.Background(), "blog", "web", config, map[string]string{"B": "v"})
57+ require.NoError(t, err)
58+}
59+
60+func TestResolveGuardedBinds_RefusesProtectedSource(t *testing.T) {
61+ protected := t.TempDir()
62+ svc := NewService(Deps{Guard: domain.RuntimeGuard{ProtectedPaths: []string{protected}}}, zerowrap.Default()).
63+ WithBindPolicies(map[string]domain.AppBindPolicy{
64+ "config": bindPolicyFor(protected, protected, true, []string{"blog"}, []string{"web"}),
65+ })
66+
67+ _, err := svc.resolveGuardedBinds("blog", domain.AppService{
68+ Name: "web",
69+ Binds: []domain.AppBind{{Name: "config", Path: "/etc/app", ReadOnly: true}},
70+ })
71+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
72+ assert.NotContains(t, err.Error(), protected)
73+}
@@ -0,0 +1,73 @@
1+package deployment
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/bnema/zerowrap"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
15+ "github.com/bnema/gordon/internal/domain"
16+)
17+
18+func TestCreateContainer_GuardRefusesHostNetwork(t *testing.T) {
19+ runtime := outmocks.NewMockContainerRuntime(t)
20+ svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
21+
22+ _, err := svc.createContainer(context.Background(), "web", &domain.ContainerConfig{Name: "c", NetworkMode: "host"})
23+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
24+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
25+}
26+
27+func TestCreateContainer_GuardRefusesProtectedBind(t *testing.T) {
28+ protected := t.TempDir()
29+ child := filepath.Join(protected, "registry")
30+ require.NoError(t, os.MkdirAll(child, 0o700))
31+ runtime := outmocks.NewMockContainerRuntime(t)
32+ svc := NewService(Deps{Runtime: runtime, Guard: domain.RuntimeGuard{ProtectedPaths: []string{protected}}}, zerowrap.Default())
33+
34+ _, err := svc.createContainer(context.Background(), "web", &domain.ContainerConfig{
35+ Name: "c",
36+ Binds: []domain.ContainerBind{{Name: "conf", Source: child, Destination: "/etc/app", ReadOnly: true}},
37+ })
38+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
39+ assert.NotContains(t, err.Error(), protected)
40+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
41+}
42+
43+func TestCreateWithSecretFiles_GuardAllowsOwnSecretBind(t *testing.T) {
44+ dataDir := t.TempDir()
45+ base := filepath.Join(dataDir, "secrets-run")
46+ store := outmocks.NewMockSecretFileStore(t)
47+ runtime := outmocks.NewMockContainerRuntime(t)
48+ guard := domain.RuntimeGuard{ProtectedPaths: []string{dataDir}, SecretFilesDir: base}
49+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store, Guard: guard}, zerowrap.Default())
50+ config := &domain.ContainerConfig{Name: "gordon-blog--web--abc", Labels: appLabels("blog", "web", "rev-1")}
51+
52+ store.EXPECT().Write(mock.Anything, "blog", "gordon-blog--web--abc", mock.Anything).
53+ Return(map[string]string{"B": filepath.Join(base, "blog", "gordon-blog--web--abc", "B")}, nil).Once()
54+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-1"}, nil).Once()
55+
56+ _, err := svc.createWithSecretFiles(context.Background(), "blog", "web", config, map[string]string{"B": "v"})
57+ require.NoError(t, err)
58+}
59+
60+func TestResolveGuardedBinds_RefusesProtectedSource(t *testing.T) {
61+ protected := t.TempDir()
62+ svc := NewService(Deps{Guard: domain.RuntimeGuard{ProtectedPaths: []string{protected}}}, zerowrap.Default()).
63+ WithBindPolicies(map[string]domain.AppBindPolicy{
64+ "config": bindPolicyFor(protected, protected, true, []string{"blog"}, []string{"web"}),
65+ })
66+
67+ _, err := svc.resolveGuardedBinds("blog", domain.AppService{
68+ Name: "web",
69+ Binds: []domain.AppBind{{Name: "config", Path: "/etc/app", ReadOnly: true}},
70+ })
71+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
72+ assert.NotContains(t, err.Error(), protected)
73+}
M internal/usecase/deployment/service.go +20 -1
@@ -51,6 +51,10 @@ type Deps struct {
5151 // nil is valid only while no service delivers secrets as files;
5252 // preflight refuses such services otherwise.
5353 SecretFiles out.SecretFileStore
54+ // Guard is checked on every app container config right before the
55+ // runtime create. The zero value still refuses host/shared network
56+ // namespaces and host paths passed as volume names.
57+ Guard domain.RuntimeGuard
5458 }
5559
5660 // RegistryConfig carries the installation registry identity for pulls.
@@ -278,6 +282,21 @@ func (s *Service) resolveServiceBinds(app string, svc domain.AppService) ([]doma
278282 return resolved, nil
279283 }
280284
285+// resolveGuardedBinds resolves the service binds and applies the runtime
286+// guard to them, so a bind overlapping a protected Gordon path fails at
287+// preflight and before the serving generation is retired, not only at
288+// create time. createContainer keeps the full guard as the final check.
289+func (s *Service) resolveGuardedBinds(app string, svc domain.AppService) ([]domain.ContainerBind, error) {
290+ binds, err := s.resolveServiceBinds(app, svc)
291+ if err != nil {
292+ return nil, err
293+ }
294+ if err := s.deps.Guard.Check(&domain.ContainerConfig{Binds: binds}); err != nil {
295+ return nil, fmt.Errorf("deployment: app %q service %q: %w", app, svc.Name, err)
296+ }
297+ return binds, nil
298+}
299+
281300 // acquireAppContext takes the GC shared lease first and the per-app
282301 // coordinator second, following the documented lock order
283302 // (GC barrier → per-app coordinator → registry lock → bbolt
@@ -719,7 +738,7 @@ func (s *Service) preflightServices(ctx context.Context, app string, rev domain.
719738 // must fail before any workload mutation. The successful values
720739 // are discarded because createAndStart re-resolves against the
721740 // current policy immediately before the runtime mutation.
722- if _, err := s.resolveServiceBinds(app, svc); err != nil {
741+ if _, err := s.resolveGuardedBinds(app, svc); err != nil {
723742 return nil, err
724743 }
725744 if err := s.preflightServiceDevices(ctx, app, svc, pinned); err != nil {
@@ -51,6 +51,10 @@ type Deps struct {
51 // nil is valid only while no service delivers secrets as files;51 // nil is valid only while no service delivers secrets as files;
52 // preflight refuses such services otherwise.52 // preflight refuses such services otherwise.
53 SecretFiles out.SecretFileStore53 SecretFiles out.SecretFileStore
54+ // Guard is checked on every app container config right before the
55+ // runtime create. The zero value still refuses host/shared network
56+ // namespaces and host paths passed as volume names.
57+ Guard domain.RuntimeGuard
54 }58 }
55 59
56 // RegistryConfig carries the installation registry identity for pulls.60 // RegistryConfig carries the installation registry identity for pulls.
@@ -278,6 +282,21 @@ func (s *Service) resolveServiceBinds(app string, svc domain.AppService) ([]doma
278 return resolved, nil282 return resolved, nil
279 }283 }
280 284
285+// resolveGuardedBinds resolves the service binds and applies the runtime
286+// guard to them, so a bind overlapping a protected Gordon path fails at
287+// preflight and before the serving generation is retired, not only at
288+// create time. createContainer keeps the full guard as the final check.
289+func (s *Service) resolveGuardedBinds(app string, svc domain.AppService) ([]domain.ContainerBind, error) {
290+ binds, err := s.resolveServiceBinds(app, svc)
291+ if err != nil {
292+ return nil, err
293+ }
294+ if err := s.deps.Guard.Check(&domain.ContainerConfig{Binds: binds}); err != nil {
295+ return nil, fmt.Errorf("deployment: app %q service %q: %w", app, svc.Name, err)
296+ }
297+ return binds, nil
298+}
299+
281 // acquireAppContext takes the GC shared lease first and the per-app300 // acquireAppContext takes the GC shared lease first and the per-app
282 // coordinator second, following the documented lock order301 // coordinator second, following the documented lock order
283 // (GC barrier → per-app coordinator → registry lock → bbolt302 // (GC barrier → per-app coordinator → registry lock → bbolt
@@ -719,7 +738,7 @@ func (s *Service) preflightServices(ctx context.Context, app string, rev domain.
719 // must fail before any workload mutation. The successful values738 // must fail before any workload mutation. The successful values
720 // are discarded because createAndStart re-resolves against the739 // are discarded because createAndStart re-resolves against the
721 // current policy immediately before the runtime mutation.740 // current policy immediately before the runtime mutation.
722- if _, err := s.resolveServiceBinds(app, svc); err != nil {741+ if _, err := s.resolveGuardedBinds(app, svc); err != nil {
723 return nil, err742 return nil, err
724 }743 }
725 if err := s.preflightServiceDevices(ctx, app, svc, pinned); err != nil {744 if err := s.preflightServiceDevices(ctx, app, svc, pinned); err != nil {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close