Protect shared metrics database directories

This commit is contained in:
Pulse Autonomous Maintainer
2026-08-11 15:57:12 +01:00
parent 3826316eec
commit f445b7fa29
7 changed files with 221 additions and 3 deletions
+27
View File
@@ -56,6 +56,33 @@ only the metrics SQLite database without moving secrets or general config:
PULSE_METRICS_DB_PATH=/dev/shm/pulse/metrics.db
```
Use a dedicated directory owned by the account running Pulse. Do not place the
database directly in a shared parent such as `/tmp/metrics.db`: Pulse secures
the database directory to mode `0700` and will reject a directory owned by
another user. On systemd installs, `PrivateTmp=true` also means a path below
`/tmp` is private to the service and will not appear in the host's `/tmp`.
The default systemd sandbox permits `/dev/shm/pulse` and Pulse can create that
subdirectory itself. A dedicated mount elsewhere, such as `/mnt/ramdisk`, also
needs an explicit writable-path grant:
```bash
sudo install -d -o pulse -g pulse -m 0700 /mnt/ramdisk/pulse
sudo systemctl edit pulse
```
Add this drop-in (replace `pulse` with the installed unit name if different):
```ini
[Service]
ReadWritePaths=/mnt/ramdisk/pulse
```
Then set `PULSE_METRICS_DB_PATH=/mnt/ramdisk/pulse/metrics.db` in
`/etc/pulse/.env` and restart Pulse. `ProtectSystem=strict` deliberately keeps
paths outside the unit's writable allowlist read-only even when Unix ownership
would otherwise permit writes.
For Docker, mount a tmpfs at the selected directory and keep `/data` on a
persistent volume:
@@ -2091,6 +2091,11 @@ store must create or re-harden the selected database directory as owner-only
and must reject symlink or non-regular database targets before opening SQLite;
the `.db`, `.db-wal`, and `.db-shm` artifacts must be chmodded owner-only even
under a permissive process umask.
That hardening must never chmod a filesystem root, sticky shared directory, or
directory owned by another account. Unsafe parents such as direct
`/tmp/metrics.db` placement must fail with dedicated-subdirectory guidance;
external systemd mounts must remain explicit `ReadWritePaths` grants rather
than weakening the default service sandbox.
That same metrics hot path must also keep startup maintenance off the
constructor critical path. `NewStore` may initialize schema and return a usable
store, but restart-time retention cleanup and one-time auto-vacuum migration
@@ -56,6 +56,33 @@ only the metrics SQLite database without moving secrets or general config:
PULSE_METRICS_DB_PATH=/dev/shm/pulse/metrics.db
```
Use a dedicated directory owned by the account running Pulse. Do not place the
database directly in a shared parent such as `/tmp/metrics.db`: Pulse secures
the database directory to mode `0700` and will reject a directory owned by
another user. On systemd installs, `PrivateTmp=true` also means a path below
`/tmp` is private to the service and will not appear in the host's `/tmp`.
The default systemd sandbox permits `/dev/shm/pulse` and Pulse can create that
subdirectory itself. A dedicated mount elsewhere, such as `/mnt/ramdisk`, also
needs an explicit writable-path grant:
```bash
sudo install -d -o pulse -g pulse -m 0700 /mnt/ramdisk/pulse
sudo systemctl edit pulse
```
Add this drop-in (replace `pulse` with the installed unit name if different):
```ini
[Service]
ReadWritePaths=/mnt/ramdisk/pulse
```
Then set `PULSE_METRICS_DB_PATH=/mnt/ramdisk/pulse/metrics.db` in
`/etc/pulse/.env` and restart Pulse. `ProtectSystem=strict` deliberately keeps
paths outside the unit's writable allowlist read-only even when Unix ownership
would otherwise permit writes.
For Docker, mount a tmpfs at the selected directory and keep `/data` on a
persistent volume:
+11
View File
@@ -0,0 +1,11 @@
//go:build !unix
package metrics
import "os"
// Platforms without Unix ownership metadata retain the existing chmod-based
// hardening. Filesystem-root and symlink targets are still rejected first.
func directoryOwnedByCurrentUser(_ os.FileInfo) bool {
return true
}
+13
View File
@@ -0,0 +1,13 @@
//go:build unix
package metrics
import (
"os"
"syscall"
)
func directoryOwnedByCurrentUser(info os.FileInfo) bool {
stat, ok := info.Sys().(*syscall.Stat_t)
return ok && uint64(stat.Uid) == uint64(os.Geteuid())
}
+34 -3
View File
@@ -251,7 +251,7 @@ func NewStore(config StoreConfig) (*Store, error) {
dir := filepath.Dir(config.DBPath)
if err := ensureOwnerOnlyDir(dir); err != nil {
return nil, fmt.Errorf("failed to create metrics directory: %w", err)
return nil, fmt.Errorf("failed to prepare metrics directory: %w", err)
}
if err := rejectSymlinkOrNonRegular(config.DBPath); err != nil && !errors.Is(err, os.ErrNotExist) {
@@ -2256,10 +2256,41 @@ func (s *Store) Close() error {
}
func ensureOwnerOnlyDir(dir string) error {
if err := os.MkdirAll(dir, privateDirPerm); err != nil {
cleanedDir := filepath.Clean(dir)
if filepath.IsAbs(cleanedDir) && filepath.Dir(cleanedDir) == cleanedDir {
return fmt.Errorf("metrics database must use a dedicated subdirectory, not filesystem root %q", cleanedDir)
}
info, err := os.Lstat(cleanedDir)
if errors.Is(err, os.ErrNotExist) {
if err := os.MkdirAll(cleanedDir, privateDirPerm); err != nil {
return err
}
info, err = os.Lstat(cleanedDir)
}
if err != nil {
return err
}
return os.Chmod(dir, privateDirPerm)
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("unsafe metrics directory %q: symlink is not allowed", cleanedDir)
}
if !info.IsDir() {
return fmt.Errorf("unsafe metrics directory %q: not a directory", cleanedDir)
}
if info.Mode()&os.ModeSticky != 0 && info.Mode().Perm()&0o022 != 0 {
return fmt.Errorf("metrics directory %q is shared; choose a dedicated Pulse-owned subdirectory", cleanedDir)
}
// Do not try to harden shared parents such as /tmp or /dev/shm. Apart from
// usually failing for the unprivileged service user, a root-run container
// could otherwise make the shared directory inaccessible to the host. A
// dedicated child remains safe and lets Pulse enforce owner-only access.
if !directoryOwnedByCurrentUser(info) {
return fmt.Errorf("metrics directory %q is not owned by the current user; choose a dedicated Pulse-owned subdirectory", cleanedDir)
}
if err := os.Chmod(cleanedDir, privateDirPerm); err != nil {
return fmt.Errorf("secure metrics directory %q to %04o (ensure it is writable in the service sandbox): %w", cleanedDir, privateDirPerm, err)
}
return nil
}
func rejectSymlinkOrNonRegular(path string) error {
+104
View File
@@ -326,6 +326,110 @@ func TestNewStoreCanonicalizesDBPath(t *testing.T) {
}
}
func TestEnsureOwnerOnlyDirHardensOwnedDirectory(t *testing.T) {
dir := filepath.Join(t.TempDir(), "metrics")
if err := os.Mkdir(dir, 0o755); err != nil {
t.Fatalf("Mkdir: %v", err)
}
if err := ensureOwnerOnlyDir(dir); err != nil {
t.Fatalf("ensureOwnerOnlyDir: %v", err)
}
info, err := os.Stat(dir)
if err != nil {
t.Fatalf("Stat: %v", err)
}
if got := info.Mode().Perm(); got != privateDirPerm {
t.Fatalf("directory permissions = %#o, want %#o", got, privateDirPerm)
}
}
func TestEnsureOwnerOnlyDirRejectsFilesystemRoot(t *testing.T) {
root := string(filepath.Separator)
before, err := os.Stat(root)
if err != nil {
t.Fatalf("Stat root: %v", err)
}
err = ensureOwnerOnlyDir(root)
if err == nil || !strings.Contains(err.Error(), "dedicated subdirectory") {
t.Fatalf("ensureOwnerOnlyDir(root) error = %v, want dedicated-subdirectory guidance", err)
}
after, err := os.Stat(root)
if err != nil {
t.Fatalf("Stat root after rejection: %v", err)
}
if after.Mode().Perm() != before.Mode().Perm() {
t.Fatalf("root permissions changed from %#o to %#o", before.Mode().Perm(), after.Mode().Perm())
}
}
func TestEnsureOwnerOnlyDirRejectsSystemTemporaryDirectory(t *testing.T) {
dir := os.TempDir()
before, err := os.Stat(dir)
if err != nil {
t.Skipf("temporary directory is unavailable: %v", err)
}
if directoryOwnedByCurrentUser(before) {
t.Skip("temporary directory is owned by the test user on this platform")
}
err = ensureOwnerOnlyDir(dir)
if err == nil || (!strings.Contains(err.Error(), "is shared") && !strings.Contains(err.Error(), "not owned by the current user")) {
t.Fatalf("ensureOwnerOnlyDir(%q) error = %v, want shared-directory or ownership guidance", dir, err)
}
after, err := os.Stat(dir)
if err != nil {
t.Fatalf("Stat shared directory after rejection: %v", err)
}
if after.Mode().Perm() != before.Mode().Perm() {
t.Fatalf("shared directory permissions changed from %#o to %#o", before.Mode().Perm(), after.Mode().Perm())
}
}
func TestEnsureOwnerOnlyDirRejectsOwnedStickySharedDirectory(t *testing.T) {
dir := filepath.Join(t.TempDir(), "shared")
if err := os.Mkdir(dir, 0o777); err != nil {
t.Fatalf("Mkdir: %v", err)
}
if err := os.Chmod(dir, os.ModeSticky|0o777); err != nil {
t.Skipf("sticky directories are unavailable: %v", err)
}
err := ensureOwnerOnlyDir(dir)
if err == nil || !strings.Contains(err.Error(), "is shared") {
t.Fatalf("ensureOwnerOnlyDir(sticky shared directory) error = %v, want shared-directory guidance", err)
}
after, err := os.Stat(dir)
if err != nil {
t.Fatalf("Stat shared directory after rejection: %v", err)
}
if after.Mode().Perm() != 0o777 || after.Mode()&os.ModeSticky == 0 {
t.Fatalf("shared directory mode changed to %v", after.Mode())
}
}
func TestEnsureOwnerOnlyDirRejectsSymlink(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "target")
link := filepath.Join(root, "metrics")
if err := os.Mkdir(target, privateDirPerm); err != nil {
t.Fatalf("Mkdir target: %v", err)
}
if err := os.Symlink(target, link); err != nil {
t.Skipf("symlinks are unavailable: %v", err)
}
err := ensureOwnerOnlyDir(link)
if err == nil || !strings.Contains(err.Error(), "symlink is not allowed") {
t.Fatalf("ensureOwnerOnlyDir(symlink) error = %v, want symlink rejection", err)
}
}
func TestStoreFilesOwnerOnlyUnderPermissiveUmask(t *testing.T) {
oldUmask := syscall.Umask(0o022)
defer syscall.Umask(oldUmask)