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
+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)