detached 01ec9bd

fix(security): allow read-only binds of Gordon log directories

commit
01ec9bdb47ced8c44e6825aa01ac38dc40280457
author
brice · · 2026-10-10 01:04 UTC
parent
b4f4f5a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +159 -18
M docs/config/security-hardening.md +5 -3
@@ -183,16 +183,18 @@ Every app container config passes a final guard right before the runtime creates
183183 Protected paths:
184184
185185 - the data directory and the loaded config file;
186-- files and directories set in the config: `server.tls_key_file`, the Gordon log file, `logging.container_logs.dir`, `logging.access_log.file_path`, and the backup `storage_dir` values;
186+- files and directories set in the config: `server.tls_key_file` and the backup `storage_dir` values;
187187 - runtime sockets: `/var/run/docker.sock`, `/run/docker.sock`, `/run/podman/podman.sock`, `$XDG_RUNTIME_DIR/docker.sock`, `$XDG_RUNTIME_DIR/podman/podman.sock`, the `unix://` path of `DOCKER_HOST`, and the `server.runtime` socket;
188188 - rootless runtime storage and config: `~/.local/share/containers`, `~/.local/share/docker`, `${XDG_CONFIG_HOME:-~/.config}/containers`, and `~/.docker`;
189189 - the systemd user units: `${XDG_CONFIG_HOME:-~/.config}/systemd/user`;
190190 - Gordon runtime credentials: `$XDG_RUNTIME_DIR/gordon`, `~/.gordon/run`, and `$GORDON_CLOUDFLARE_API_TOKEN_FILE`;
191191 - key stores: `$GNUPGHOME`, `~/.gnupg`, `$PASSWORD_STORE_DIR`, `~/.password-store`, `$SOPS_AGE_KEY_FILE`, and `${XDG_CONFIG_HOME:-~/.config}/sops/age`.
192192
193-A protected path that does not exist yet is also protected in its symlink-resolved form, based on its closest existing parent. Container log and backup directories are protected too: a log-shipper or backup app cannot bind them, even read-only.
193+A protected path that does not exist yet is also protected in its symlink-resolved form, based on its closest existing parent. Backup directories are protected: a backup app cannot bind them, even read-only.
194194
195-The only exception is Gordon's own read-only secret file mount: `<data_dir>/secrets-run/<app>/<container>/<KEY>` at `/run/secrets/<KEY>`. A refused `[app_mounts]` policy fails before the running generation is replaced or restarted. Point the policy `source` outside every protected path.
195+Gordon's log directories (the Gordon log file's directory, the access log's directory, and `logging.container_logs.dir`) can be bound read-only, even under the data directory, so a log shipper can read them. A writable bind on them is refused, as is a log directory that is or contains another protected path.
196+
197+The only other exception is Gordon's own read-only secret file mount: `<data_dir>/secrets-run/<app>/<container>/<KEY>` at `/run/secrets/<KEY>`. A refused `[app_mounts]` policy fails before the running generation is replaced or restarted. Point the policy `source` outside every protected path.
196198
197199 ## Process memory protection
198200
@@ -183,16 +183,18 @@ Every app container config passes a final guard right before the runtime creates
183 Protected paths:183 Protected paths:
184 184
185 - the data directory and the loaded config file;185 - the data directory and the loaded config file;
186-- files and directories set in the config: `server.tls_key_file`, the Gordon log file, `logging.container_logs.dir`, `logging.access_log.file_path`, and the backup `storage_dir` values;186+- files and directories set in the config: `server.tls_key_file` and the backup `storage_dir` values;
187 - runtime sockets: `/var/run/docker.sock`, `/run/docker.sock`, `/run/podman/podman.sock`, `$XDG_RUNTIME_DIR/docker.sock`, `$XDG_RUNTIME_DIR/podman/podman.sock`, the `unix://` path of `DOCKER_HOST`, and the `server.runtime` socket;187 - runtime sockets: `/var/run/docker.sock`, `/run/docker.sock`, `/run/podman/podman.sock`, `$XDG_RUNTIME_DIR/docker.sock`, `$XDG_RUNTIME_DIR/podman/podman.sock`, the `unix://` path of `DOCKER_HOST`, and the `server.runtime` socket;
188 - rootless runtime storage and config: `~/.local/share/containers`, `~/.local/share/docker`, `${XDG_CONFIG_HOME:-~/.config}/containers`, and `~/.docker`;188 - rootless runtime storage and config: `~/.local/share/containers`, `~/.local/share/docker`, `${XDG_CONFIG_HOME:-~/.config}/containers`, and `~/.docker`;
189 - the systemd user units: `${XDG_CONFIG_HOME:-~/.config}/systemd/user`;189 - the systemd user units: `${XDG_CONFIG_HOME:-~/.config}/systemd/user`;
190 - Gordon runtime credentials: `$XDG_RUNTIME_DIR/gordon`, `~/.gordon/run`, and `$GORDON_CLOUDFLARE_API_TOKEN_FILE`;190 - Gordon runtime credentials: `$XDG_RUNTIME_DIR/gordon`, `~/.gordon/run`, and `$GORDON_CLOUDFLARE_API_TOKEN_FILE`;
191 - key stores: `$GNUPGHOME`, `~/.gnupg`, `$PASSWORD_STORE_DIR`, `~/.password-store`, `$SOPS_AGE_KEY_FILE`, and `${XDG_CONFIG_HOME:-~/.config}/sops/age`.191 - key stores: `$GNUPGHOME`, `~/.gnupg`, `$PASSWORD_STORE_DIR`, `~/.password-store`, `$SOPS_AGE_KEY_FILE`, and `${XDG_CONFIG_HOME:-~/.config}/sops/age`.
192 192
193-A protected path that does not exist yet is also protected in its symlink-resolved form, based on its closest existing parent. Container log and backup directories are protected too: a log-shipper or backup app cannot bind them, even read-only.193+A protected path that does not exist yet is also protected in its symlink-resolved form, based on its closest existing parent. Backup directories are protected: a backup app cannot bind them, even read-only.
194 194
195-The only exception is Gordon's own read-only secret file mount: `<data_dir>/secrets-run/<app>/<container>/<KEY>` at `/run/secrets/<KEY>`. A refused `[app_mounts]` policy fails before the running generation is replaced or restarted. Point the policy `source` outside every protected path.195+Gordon's log directories (the Gordon log file's directory, the access log's directory, and `logging.container_logs.dir`) can be bound read-only, even under the data directory, so a log shipper can read them. A writable bind on them is refused, as is a log directory that is or contains another protected path.
196+
197+The only other exception is Gordon's own read-only secret file mount: `<data_dir>/secrets-run/<app>/<container>/<KEY>` at `/run/secrets/<KEY>`. A refused `[app_mounts]` policy fails before the running generation is replaced or restarted. Point the policy `source` outside every protected path.
196 198
197 ## Process memory protection199 ## Process memory protection
198 200
M internal/app/runtime_guard.go +62 -7
@@ -49,6 +49,12 @@ func protectedRuntimePaths(dataDir string, gordonPaths []string, getenv func(str
4949 getenv("SOPS_AGE_KEY_FILE"),
5050 subPath(configHome, "sops", "age"),
5151 }, gordonPaths...)
52+ return absWithResolved(raw)
53+}
54+
55+// absWithResolved makes each non-empty path absolute, adds its
56+// symlink-resolved form when it differs, and returns the sorted set.
57+func absWithResolved(raw []string) []string {
5258 var paths []string
5359 for _, p := range raw {
5460 if p == "" {
@@ -102,16 +108,13 @@ func homePath(home string, elems ...string) string {
102108 }
103109
104110 // configuredGordonPaths returns the config file, the runtime socket in use,
105-// and every private file or directory the config points Gordon at. The
106-// public TLS certificate is left out so its directory (often a shared CA
107-// bundle) stays bindable.
111+// and every private file or directory the config points Gordon at. Log
112+// paths are left to gordonLogDirs. The public TLS certificate is left out
113+// so its directory (often a shared CA bundle) stays bindable.
108114 func configuredGordonPaths(si *serviceInit) []string {
109115 cfg := si.cfg
110116 paths := []string{
111117 cfg.Server.TLSKeyFile,
112- resolveLogFilePath(cfg),
113- cfg.Logging.ContainerLogs.Dir,
114- cfg.Logging.AccessLog.FilePath,
115118 cfg.Backups.StorageDir,
116119 cfg.Backups.Databases.StorageDir,
117120 }
@@ -121,6 +124,54 @@ func configuredGordonPaths(si *serviceInit) []string {
121124 return paths
122125 }
123126
127+// readableLogDirs keeps the log dirs that lie under the data dir or
128+// outside every protected path, so a log dir misconfigured inside a key
129+// store or runtime dir never opens a read-only exception there.
130+func readableLogDirs(logDirs, protected []string, dataDir string) []string {
131+ dataDirs := absWithResolved([]string{dataDir})
132+ var kept []string
133+ for _, logDir := range logDirs {
134+ ok := true
135+ for _, p := range protected {
136+ if slices.Contains(dataDirs, p) {
137+ continue
138+ }
139+ if pathWithin(p, logDir) || pathWithin(logDir, p) {
140+ ok = false
141+ break
142+ }
143+ }
144+ if ok {
145+ kept = append(kept, logDir)
146+ }
147+ }
148+ return kept
149+}
150+
151+// pathWithin reports whether target is root or lies under it.
152+func pathWithin(root, target string) bool {
153+ rel, err := filepath.Rel(root, target)
154+ return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
155+}
156+
157+// gordonLogDirs returns the directories of Gordon's process log, access
158+// log, and container logs. App binds may read them but never write them.
159+func gordonLogDirs(cfg Config, dataDir string) []string {
160+ containerLogs := cfg.Logging.ContainerLogs.Dir
161+ if containerLogs == "" {
162+ containerLogs = filepath.Join(dataDir, "logs", "containers")
163+ }
164+ accessLog := cfg.Logging.AccessLog.FilePath
165+ if accessLog == "" {
166+ accessLog = filepath.Join(dataDir, "logs", "access.log")
167+ }
168+ return absWithResolved([]string{
169+ filepath.Dir(resolveLogFilePath(cfg)),
170+ filepath.Dir(accessLog),
171+ containerLogs,
172+ })
173+}
174+
124175 // buildAppRuntimeSafety opens the host secret file store and builds the
125176 // runtime guard applied to every app container create. The store keeps one
126177 // directory handle open for the daemon's lifetime.
@@ -131,5 +182,9 @@ func buildAppRuntimeSafety(si *serviceInit, dataDir string) (out.SecretFileStore
131182 }
132183 home, _ := os.UserHomeDir()
133184 paths := protectedRuntimePaths(dataDir, configuredGordonPaths(si), os.Getenv, home)
134- return store, domain.RuntimeGuard{ProtectedPaths: paths, SecretFilesDir: store.Dir()}, nil
185+ return store, domain.RuntimeGuard{
186+ ProtectedPaths: paths,
187+ SecretFilesDir: store.Dir(),
188+ LogDirs: readableLogDirs(gordonLogDirs(si.cfg, dataDir), paths, dataDir),
189+ }, nil
135190 }
@@ -49,6 +49,12 @@ func protectedRuntimePaths(dataDir string, gordonPaths []string, getenv func(str
49 getenv("SOPS_AGE_KEY_FILE"),49 getenv("SOPS_AGE_KEY_FILE"),
50 subPath(configHome, "sops", "age"),50 subPath(configHome, "sops", "age"),
51 }, gordonPaths...)51 }, gordonPaths...)
52+ return absWithResolved(raw)
53+}
54+
55+// absWithResolved makes each non-empty path absolute, adds its
56+// symlink-resolved form when it differs, and returns the sorted set.
57+func absWithResolved(raw []string) []string {
52 var paths []string58 var paths []string
53 for _, p := range raw {59 for _, p := range raw {
54 if p == "" {60 if p == "" {
@@ -102,16 +108,13 @@ func homePath(home string, elems ...string) string {
102 }108 }
103 109
104 // configuredGordonPaths returns the config file, the runtime socket in use,110 // configuredGordonPaths returns the config file, the runtime socket in use,
105-// and every private file or directory the config points Gordon at. The111+// and every private file or directory the config points Gordon at. Log
106-// public TLS certificate is left out so its directory (often a shared CA112+// paths are left to gordonLogDirs. The public TLS certificate is left out
107-// bundle) stays bindable.113+// so its directory (often a shared CA bundle) stays bindable.
108 func configuredGordonPaths(si *serviceInit) []string {114 func configuredGordonPaths(si *serviceInit) []string {
109 cfg := si.cfg115 cfg := si.cfg
110 paths := []string{116 paths := []string{
111 cfg.Server.TLSKeyFile,117 cfg.Server.TLSKeyFile,
112- resolveLogFilePath(cfg),
113- cfg.Logging.ContainerLogs.Dir,
114- cfg.Logging.AccessLog.FilePath,
115 cfg.Backups.StorageDir,118 cfg.Backups.StorageDir,
116 cfg.Backups.Databases.StorageDir,119 cfg.Backups.Databases.StorageDir,
117 }120 }
@@ -121,6 +124,54 @@ func configuredGordonPaths(si *serviceInit) []string {
121 return paths124 return paths
122 }125 }
123 126
127+// readableLogDirs keeps the log dirs that lie under the data dir or
128+// outside every protected path, so a log dir misconfigured inside a key
129+// store or runtime dir never opens a read-only exception there.
130+func readableLogDirs(logDirs, protected []string, dataDir string) []string {
131+ dataDirs := absWithResolved([]string{dataDir})
132+ var kept []string
133+ for _, logDir := range logDirs {
134+ ok := true
135+ for _, p := range protected {
136+ if slices.Contains(dataDirs, p) {
137+ continue
138+ }
139+ if pathWithin(p, logDir) || pathWithin(logDir, p) {
140+ ok = false
141+ break
142+ }
143+ }
144+ if ok {
145+ kept = append(kept, logDir)
146+ }
147+ }
148+ return kept
149+}
150+
151+// pathWithin reports whether target is root or lies under it.
152+func pathWithin(root, target string) bool {
153+ rel, err := filepath.Rel(root, target)
154+ return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
155+}
156+
157+// gordonLogDirs returns the directories of Gordon's process log, access
158+// log, and container logs. App binds may read them but never write them.
159+func gordonLogDirs(cfg Config, dataDir string) []string {
160+ containerLogs := cfg.Logging.ContainerLogs.Dir
161+ if containerLogs == "" {
162+ containerLogs = filepath.Join(dataDir, "logs", "containers")
163+ }
164+ accessLog := cfg.Logging.AccessLog.FilePath
165+ if accessLog == "" {
166+ accessLog = filepath.Join(dataDir, "logs", "access.log")
167+ }
168+ return absWithResolved([]string{
169+ filepath.Dir(resolveLogFilePath(cfg)),
170+ filepath.Dir(accessLog),
171+ containerLogs,
172+ })
173+}
174+
124 // buildAppRuntimeSafety opens the host secret file store and builds the175 // buildAppRuntimeSafety opens the host secret file store and builds the
125 // runtime guard applied to every app container create. The store keeps one176 // runtime guard applied to every app container create. The store keeps one
126 // directory handle open for the daemon's lifetime.177 // directory handle open for the daemon's lifetime.
@@ -131,5 +182,9 @@ func buildAppRuntimeSafety(si *serviceInit, dataDir string) (out.SecretFileStore
131 }182 }
132 home, _ := os.UserHomeDir()183 home, _ := os.UserHomeDir()
133 paths := protectedRuntimePaths(dataDir, configuredGordonPaths(si), os.Getenv, home)184 paths := protectedRuntimePaths(dataDir, configuredGordonPaths(si), os.Getenv, home)
134- return store, domain.RuntimeGuard{ProtectedPaths: paths, SecretFilesDir: store.Dir()}, nil185+ return store, domain.RuntimeGuard{
186+ ProtectedPaths: paths,
187+ SecretFilesDir: store.Dir(),
188+ LogDirs: readableLogDirs(gordonLogDirs(si.cfg, dataDir), paths, dataDir),
189+ }, nil
135 }190 }
M internal/app/runtime_guard_test.go +15 -2
@@ -94,9 +94,16 @@ func TestConfiguredGordonPaths(t *testing.T) {
9494
9595 got := configuredGordonPaths(si)
9696 assert.Contains(t, got, "/tls/key.pem")
97- assert.Contains(t, got, "/data/logs/gordon.log")
98- assert.Contains(t, got, "/logs/access.log")
9997 assert.Contains(t, got, "/backups")
98+ assert.NotContains(t, got, "/logs/access.log")
99+
100+ assert.ElementsMatch(t, []string{"/data/logs", "/data/logs/containers", "/logs"}, gordonLogDirs(si.cfg, "/data"))
101+}
102+
103+func TestReadableLogDirs(t *testing.T) {
104+ protected := []string{"/data", "/home/u/.gnupg", "/srv/logs/keys"}
105+ got := readableLogDirs([]string{"/data/logs", "/home/u/.gnupg/logs", "/srv/logs", "/var/log/gordon"}, protected, "/data")
106+ assert.Equal(t, []string{"/data/logs", "/var/log/gordon"}, got)
100107 }
101108
102109 func TestBuildAppRuntimeSafety(t *testing.T) {
@@ -110,4 +117,10 @@ func TestBuildAppRuntimeSafety(t *testing.T) {
110117 // The data dir itself cannot be bound into an app.
111118 err = guard.Check(&domain.ContainerConfig{Binds: []domain.ContainerBind{{Name: "all", Source: dataDir, Destination: "/data"}}})
112119 require.ErrorIs(t, err, domain.ErrRuntimeGuard)
120+
121+ // The default log dir under the data dir is readable, never writable.
122+ logs := filepath.Join(dataDir, "logs")
123+ require.NoError(t, guard.Check(&domain.ContainerConfig{Binds: []domain.ContainerBind{{Name: "logs", Source: logs, Destination: "/var/log/gordon", ReadOnly: true}}}))
124+ err = guard.Check(&domain.ContainerConfig{Binds: []domain.ContainerBind{{Name: "logs", Source: logs, Destination: "/var/log/gordon"}}})
125+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
113126 }
@@ -94,9 +94,16 @@ func TestConfiguredGordonPaths(t *testing.T) {
94 94
95 got := configuredGordonPaths(si)95 got := configuredGordonPaths(si)
96 assert.Contains(t, got, "/tls/key.pem")96 assert.Contains(t, got, "/tls/key.pem")
97- assert.Contains(t, got, "/data/logs/gordon.log")
98- assert.Contains(t, got, "/logs/access.log")
99 assert.Contains(t, got, "/backups")97 assert.Contains(t, got, "/backups")
98+ assert.NotContains(t, got, "/logs/access.log")
99+
100+ assert.ElementsMatch(t, []string{"/data/logs", "/data/logs/containers", "/logs"}, gordonLogDirs(si.cfg, "/data"))
101+}
102+
103+func TestReadableLogDirs(t *testing.T) {
104+ protected := []string{"/data", "/home/u/.gnupg", "/srv/logs/keys"}
105+ got := readableLogDirs([]string{"/data/logs", "/home/u/.gnupg/logs", "/srv/logs", "/var/log/gordon"}, protected, "/data")
106+ assert.Equal(t, []string{"/data/logs", "/var/log/gordon"}, got)
100 }107 }
101 108
102 func TestBuildAppRuntimeSafety(t *testing.T) {109 func TestBuildAppRuntimeSafety(t *testing.T) {
@@ -110,4 +117,10 @@ func TestBuildAppRuntimeSafety(t *testing.T) {
110 // The data dir itself cannot be bound into an app.117 // The data dir itself cannot be bound into an app.
111 err = guard.Check(&domain.ContainerConfig{Binds: []domain.ContainerBind{{Name: "all", Source: dataDir, Destination: "/data"}}})118 err = guard.Check(&domain.ContainerConfig{Binds: []domain.ContainerBind{{Name: "all", Source: dataDir, Destination: "/data"}}})
112 require.ErrorIs(t, err, domain.ErrRuntimeGuard)119 require.ErrorIs(t, err, domain.ErrRuntimeGuard)
120+
121+ // The default log dir under the data dir is readable, never writable.
122+ logs := filepath.Join(dataDir, "logs")
123+ require.NoError(t, guard.Check(&domain.ContainerConfig{Binds: []domain.ContainerBind{{Name: "logs", Source: logs, Destination: "/var/log/gordon", ReadOnly: true}}}))
124+ err = guard.Check(&domain.ContainerConfig{Binds: []domain.ContainerBind{{Name: "logs", Source: logs, Destination: "/var/log/gordon"}}})
125+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
113 }126 }
M internal/domain/runtime_guard.go +46 -6
@@ -20,6 +20,12 @@ type RuntimeGuard struct {
2020 // (<data_dir>/secrets-run). Gordon's own read-only secret binds under
2121 // it are the only allowed exception.
2222 SecretFilesDir string
23+ // LogDirs are Gordon's log directories. Binds overlapping them are
24+ // refused unless read-only; a read-only bind at or under one is
25+ // allowed even when a protected path (the data dir) contains it, so
26+ // a log shipper can read them. A log dir that is or contains a
27+ // protected path grants no exception.
28+ LogDirs []string
2329 }
2430
2531 // Check refuses a config that breaks the guard. Errors wrap
@@ -39,7 +45,7 @@ func (g RuntimeGuard) Check(config *ContainerConfig) error {
3945 if g.isOwnSecretBind(config, bind) {
4046 continue
4147 }
42- if g.overlapsProtected(bind.Source) {
48+ if g.overlapsProtected(bind) {
4349 return fmt.Errorf("%w: bind %q overlaps a protected Gordon path", ErrRuntimeGuard, bind.Name)
4450 }
4551 }
@@ -91,14 +97,48 @@ func (g RuntimeGuard) isOwnSecretBind(config *ContainerConfig, bind ContainerBin
9197 bind.Destination == SecretFilePath(key)
9298 }
9399
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) {
100+// overlapsProtected reports whether the bind source, raw or
101+// symlink-resolved, is equal to, under, or an ancestor of a protected
102+// path, or of a log dir when the bind is writable. A read-only source at
103+// or under a usable log dir only fails when it contains a protected path.
104+func (g RuntimeGuard) overlapsProtected(bind ContainerBind) bool {
105+ for _, candidate := range guardCandidates(bind.Source) {
106+ readableLog := bind.ReadOnly && g.underUsableLogDir(candidate)
98107 for _, protected := range g.ProtectedPaths {
99- if resolvedUnderRoot(protected, candidate) || resolvedUnderRoot(candidate, protected) {
108+ if resolvedUnderRoot(candidate, protected) {
100109 return true
101110 }
111+ if !readableLog && resolvedUnderRoot(protected, candidate) {
112+ return true
113+ }
114+ }
115+ if bind.ReadOnly {
116+ continue
117+ }
118+ for _, logDir := range g.LogDirs {
119+ if resolvedUnderRoot(logDir, candidate) || resolvedUnderRoot(candidate, logDir) {
120+ return true
121+ }
122+ }
123+ }
124+ return false
125+}
126+
127+// underUsableLogDir reports whether path is at or under a log dir that
128+// neither is nor contains a protected path.
129+func (g RuntimeGuard) underUsableLogDir(path string) bool {
130+ for _, logDir := range g.LogDirs {
131+ if resolvedUnderRoot(logDir, path) && !g.containsProtected(logDir) {
132+ return true
133+ }
134+ }
135+ return false
136+}
137+
138+func (g RuntimeGuard) containsProtected(dir string) bool {
139+ for _, protected := range g.ProtectedPaths {
140+ if resolvedUnderRoot(dir, protected) {
141+ return true
102142 }
103143 }
104144 return false
@@ -20,6 +20,12 @@ type RuntimeGuard struct {
20 // (<data_dir>/secrets-run). Gordon's own read-only secret binds under20 // (<data_dir>/secrets-run). Gordon's own read-only secret binds under
21 // it are the only allowed exception.21 // it are the only allowed exception.
22 SecretFilesDir string22 SecretFilesDir string
23+ // LogDirs are Gordon's log directories. Binds overlapping them are
24+ // refused unless read-only; a read-only bind at or under one is
25+ // allowed even when a protected path (the data dir) contains it, so
26+ // a log shipper can read them. A log dir that is or contains a
27+ // protected path grants no exception.
28+ LogDirs []string
23 }29 }
24 30
25 // Check refuses a config that breaks the guard. Errors wrap31 // Check refuses a config that breaks the guard. Errors wrap
@@ -39,7 +45,7 @@ func (g RuntimeGuard) Check(config *ContainerConfig) error {
39 if g.isOwnSecretBind(config, bind) {45 if g.isOwnSecretBind(config, bind) {
40 continue46 continue
41 }47 }
42- if g.overlapsProtected(bind.Source) {48+ if g.overlapsProtected(bind) {
43 return fmt.Errorf("%w: bind %q overlaps a protected Gordon path", ErrRuntimeGuard, bind.Name)49 return fmt.Errorf("%w: bind %q overlaps a protected Gordon path", ErrRuntimeGuard, bind.Name)
44 }50 }
45 }51 }
@@ -91,14 +97,48 @@ func (g RuntimeGuard) isOwnSecretBind(config *ContainerConfig, bind ContainerBin
91 bind.Destination == SecretFilePath(key)97 bind.Destination == SecretFilePath(key)
92 }98 }
93 99
94-// overlapsProtected reports whether source, raw or symlink-resolved, is100+// overlapsProtected reports whether the bind source, raw or
95-// equal to, under, or an ancestor of a protected path.101+// symlink-resolved, is equal to, under, or an ancestor of a protected
96-func (g RuntimeGuard) overlapsProtected(source string) bool {102+// path, or of a log dir when the bind is writable. A read-only source at
97- for _, candidate := range guardCandidates(source) {103+// or under a usable log dir only fails when it contains a protected path.
104+func (g RuntimeGuard) overlapsProtected(bind ContainerBind) bool {
105+ for _, candidate := range guardCandidates(bind.Source) {
106+ readableLog := bind.ReadOnly && g.underUsableLogDir(candidate)
98 for _, protected := range g.ProtectedPaths {107 for _, protected := range g.ProtectedPaths {
99- if resolvedUnderRoot(protected, candidate) || resolvedUnderRoot(candidate, protected) {108+ if resolvedUnderRoot(candidate, protected) {
100 return true109 return true
101 }110 }
111+ if !readableLog && resolvedUnderRoot(protected, candidate) {
112+ return true
113+ }
114+ }
115+ if bind.ReadOnly {
116+ continue
117+ }
118+ for _, logDir := range g.LogDirs {
119+ if resolvedUnderRoot(logDir, candidate) || resolvedUnderRoot(candidate, logDir) {
120+ return true
121+ }
122+ }
123+ }
124+ return false
125+}
126+
127+// underUsableLogDir reports whether path is at or under a log dir that
128+// neither is nor contains a protected path.
129+func (g RuntimeGuard) underUsableLogDir(path string) bool {
130+ for _, logDir := range g.LogDirs {
131+ if resolvedUnderRoot(logDir, path) && !g.containsProtected(logDir) {
132+ return true
133+ }
134+ }
135+ return false
136+}
137+
138+func (g RuntimeGuard) containsProtected(dir string) bool {
139+ for _, protected := range g.ProtectedPaths {
140+ if resolvedUnderRoot(dir, protected) {
141+ return true
102 }142 }
103 }143 }
104 return false144 return false
M internal/domain/runtime_guard_test.go +31 -0
@@ -112,3 +112,34 @@ func TestRuntimeGuard_OwnSecretBindException(t *testing.T) {
112112 })
113113 }
114114 }
115+
116+func TestRuntimeGuard_ReadOnlyLogDirs(t *testing.T) {
117+ dataDir := t.TempDir()
118+ logs := filepath.Join(dataDir, "logs")
119+ require.NoError(t, os.MkdirAll(filepath.Join(logs, "containers"), 0o700))
120+ keys := filepath.Join(dataDir, "logs", "keys")
121+ guard := domain.RuntimeGuard{ProtectedPaths: []string{dataDir}, LogDirs: []string{logs}}
122+ bind := func(source string, readOnly bool) *domain.ContainerConfig {
123+ return guardConfig(domain.ContainerBind{Name: "logs", Source: source, Destination: "/var/log/gordon", ReadOnly: readOnly})
124+ }
125+
126+ require.NoError(t, guard.Check(bind(logs, true)))
127+ require.NoError(t, guard.Check(bind(filepath.Join(logs, "containers"), true)))
128+
129+ withProtected := domain.RuntimeGuard{ProtectedPaths: []string{dataDir, keys}, LogDirs: []string{logs}}
130+ refused := map[string]struct {
131+ guard domain.RuntimeGuard
132+ cfg *domain.ContainerConfig
133+ }{
134+ "writable log dir": {guard, bind(logs, false)},
135+ "writable log subdir": {guard, bind(filepath.Join(logs, "containers"), false)},
136+ "data dir read-only": {guard, bind(dataDir, true)},
137+ "sibling of logs": {guard, bind(filepath.Join(dataDir, "registry"), true)},
138+ "log dir with protected": {withProtected, bind(logs, true)},
139+ }
140+ for name, tc := range refused {
141+ t.Run(name, func(t *testing.T) {
142+ require.ErrorIs(t, tc.guard.Check(tc.cfg), domain.ErrRuntimeGuard)
143+ })
144+ }
145+}
@@ -112,3 +112,34 @@ func TestRuntimeGuard_OwnSecretBindException(t *testing.T) {
112 })112 })
113 }113 }
114 }114 }
115+
116+func TestRuntimeGuard_ReadOnlyLogDirs(t *testing.T) {
117+ dataDir := t.TempDir()
118+ logs := filepath.Join(dataDir, "logs")
119+ require.NoError(t, os.MkdirAll(filepath.Join(logs, "containers"), 0o700))
120+ keys := filepath.Join(dataDir, "logs", "keys")
121+ guard := domain.RuntimeGuard{ProtectedPaths: []string{dataDir}, LogDirs: []string{logs}}
122+ bind := func(source string, readOnly bool) *domain.ContainerConfig {
123+ return guardConfig(domain.ContainerBind{Name: "logs", Source: source, Destination: "/var/log/gordon", ReadOnly: readOnly})
124+ }
125+
126+ require.NoError(t, guard.Check(bind(logs, true)))
127+ require.NoError(t, guard.Check(bind(filepath.Join(logs, "containers"), true)))
128+
129+ withProtected := domain.RuntimeGuard{ProtectedPaths: []string{dataDir, keys}, LogDirs: []string{logs}}
130+ refused := map[string]struct {
131+ guard domain.RuntimeGuard
132+ cfg *domain.ContainerConfig
133+ }{
134+ "writable log dir": {guard, bind(logs, false)},
135+ "writable log subdir": {guard, bind(filepath.Join(logs, "containers"), false)},
136+ "data dir read-only": {guard, bind(dataDir, true)},
137+ "sibling of logs": {guard, bind(filepath.Join(dataDir, "registry"), true)},
138+ "log dir with protected": {withProtected, bind(logs, true)},
139+ }
140+ for name, tc := range refused {
141+ t.Run(name, func(t *testing.T) {
142+ require.ErrorIs(t, tc.guard.Check(tc.cfg), domain.ErrRuntimeGuard)
143+ })
144+ }
145+}

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