fix(security): allow read-only binds of Gordon log directories
- commit
- 01ec9bdb47ced8c44e6825aa01ac38dc40280457
- author
- brice · · 2026-10-10 01:04 UTC
- parent
- b4f4f5a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 183 | 183 | Protected paths: | |
| 184 | 184 | ||
| 185 | 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 | 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 | 188 | - rootless runtime storage and config: `~/.local/share/containers`, `~/.local/share/docker`, `${XDG_CONFIG_HOME:-~/.config}/containers`, and `~/.docker`; | |
| 189 | 189 | - the systemd user units: `${XDG_CONFIG_HOME:-~/.config}/systemd/user`; | |
| 190 | 190 | - Gordon runtime credentials: `$XDG_RUNTIME_DIR/gordon`, `~/.gordon/run`, and `$GORDON_CLOUDFLARE_API_TOKEN_FILE`; | |
| 191 | 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 | 199 | ## Process memory protection | |
| 198 | 200 | ||
| @@ -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 protection | 199 | ## 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 | |||
| 49 | 49 | getenv("SOPS_AGE_KEY_FILE"), | |
| 50 | 50 | subPath(configHome, "sops", "age"), | |
| 51 | 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 | 58 | var paths []string | |
| 53 | 59 | for _, p := range raw { | |
| 54 | 60 | if p == "" { | |
| @@ -102,16 +108,13 @@ func homePath(home string, elems ...string) string { | |||
| 102 | 108 | } | |
| 103 | 109 | ||
| 104 | 110 | // 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. | |
| 108 | 114 | func configuredGordonPaths(si *serviceInit) []string { | |
| 109 | 115 | cfg := si.cfg | |
| 110 | 116 | paths := []string{ | |
| 111 | 117 | cfg.Server.TLSKeyFile, | |
| 112 | - | resolveLogFilePath(cfg), | |
| 113 | - | cfg.Logging.ContainerLogs.Dir, | |
| 114 | - | cfg.Logging.AccessLog.FilePath, | |
| 115 | 118 | cfg.Backups.StorageDir, | |
| 116 | 119 | cfg.Backups.Databases.StorageDir, | |
| 117 | 120 | } | |
| @@ -121,6 +124,54 @@ func configuredGordonPaths(si *serviceInit) []string { | |||
| 121 | 124 | 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 | 175 | // buildAppRuntimeSafety opens the host secret file store and builds the | |
| 125 | 176 | // runtime guard applied to every app container create. The store keeps one | |
| 126 | 177 | // directory handle open for the daemon's lifetime. | |
| @@ -131,5 +182,9 @@ func buildAppRuntimeSafety(si *serviceInit, dataDir string) (out.SecretFileStore | |||
| 131 | 182 | } | |
| 132 | 183 | home, _ := os.UserHomeDir() | |
| 133 | 184 | 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 | |
| 135 | 190 | } | |
| @@ -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 []string | 58 | 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. The | 111 | + | // 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 CA | 112 | + | // 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.cfg | 115 | 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 paths | 124 | 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 the | 175 | // buildAppRuntimeSafety opens the host secret file store and builds the | ||
| 125 | // runtime guard applied to every app container create. The store keeps one | 176 | // 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()}, nil | 185 | + | 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) { | |||
| 94 | 94 | ||
| 95 | 95 | got := configuredGordonPaths(si) | |
| 96 | 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 | 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 | 109 | func TestBuildAppRuntimeSafety(t *testing.T) { | |
| @@ -110,4 +117,10 @@ func TestBuildAppRuntimeSafety(t *testing.T) { | |||
| 110 | 117 | // The data dir itself cannot be bound into an app. | |
| 111 | 118 | err = guard.Check(&domain.ContainerConfig{Binds: []domain.ContainerBind{{Name: "all", Source: dataDir, Destination: "/data"}}}) | |
| 112 | 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 | } | |
| @@ -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 { | |||
| 20 | 20 | // (<data_dir>/secrets-run). Gordon's own read-only secret binds under | |
| 21 | 21 | // it are the only allowed exception. | |
| 22 | 22 | 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 | 31 | // Check refuses a config that breaks the guard. Errors wrap | |
| @@ -39,7 +45,7 @@ func (g RuntimeGuard) Check(config *ContainerConfig) error { | |||
| 39 | 45 | if g.isOwnSecretBind(config, bind) { | |
| 40 | 46 | continue | |
| 41 | 47 | } | |
| 42 | - | if g.overlapsProtected(bind.Source) { | |
| 48 | + | if g.overlapsProtected(bind) { | |
| 43 | 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 | 97 | bind.Destination == SecretFilePath(key) | |
| 92 | 98 | } | |
| 93 | 99 | ||
| 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) | |
| 98 | 107 | for _, protected := range g.ProtectedPaths { | |
| 99 | - | if resolvedUnderRoot(protected, candidate) || resolvedUnderRoot(candidate, protected) { | |
| 108 | + | if resolvedUnderRoot(candidate, protected) { | |
| 100 | 109 | 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 | 144 | return false | |
| @@ -20,6 +20,12 @@ type RuntimeGuard struct { | |||||
| 20 | // (<data_dir>/secrets-run). Gordon's own read-only secret binds under | 20 | // (<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 string | 22 | 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 wrap | 31 | // 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 | continue | 46 | 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, is | 100 | + | // 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 true | 109 | 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 false | 144 | return false | ||
M internal/domain/runtime_guard_test.go +31 -0
| @@ -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 | + | } | |
| @@ -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 | + | } | |||