check / check (push) Waiting to run
A hard link in /data shares its owner with a file that can be anywhere on the same filesystem, so chowning it gave that file to the netwatch user. A regular file with more than one name now keeps its owner and its path is logged. Owners are now set on the opened directory or file after checking it, as chmodDir sets modes, so a hard link swapped in afterwards is caught. Symbolic links and other files that are neither directories nor regular files are no longer given away: they cannot be opened safely, and setting their owner by name could reach a hard link swapped in. Model: opus-5-5
314 lines
7.3 KiB
Go
314 lines
7.3 KiB
Go
package reportbuf_test
|
|
|
|
import (
|
|
"errors"
|
|
"io/fs"
|
|
"os"
|
|
"os/user"
|
|
"path/filepath"
|
|
"strconv"
|
|
"syscall"
|
|
"testing"
|
|
|
|
"sneak.berlin/go/netwatch/internal/reportbuf"
|
|
)
|
|
|
|
// reports is the last part of DATA_DIR in these tests, as in the
|
|
// image's /data/reports.
|
|
const reports = "reports"
|
|
|
|
// currentUser is the user the test runs as, the only owner a test not
|
|
// run as root can give files to.
|
|
func currentUser() *user.User {
|
|
return &user.User{
|
|
Uid: strconv.Itoa(os.Getuid()),
|
|
Gid: strconv.Itoa(os.Getgid()),
|
|
}
|
|
}
|
|
|
|
// tempDirMode700 is a new directory in a t.TempDir with mode 0700, so
|
|
// a test can tell that PrepareDataDir left its mode alone.
|
|
func tempDirMode700(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
dir := filepath.Join(t.TempDir(), "d")
|
|
|
|
err := os.Mkdir(dir, 0o700)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
return dir
|
|
}
|
|
|
|
func requireMode(t *testing.T, path string, want fs.FileMode) {
|
|
t.Helper()
|
|
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if info.Mode() != want {
|
|
t.Errorf("%s: mode %v, want %v", path, info.Mode(), want)
|
|
}
|
|
}
|
|
|
|
func requireMissing(t *testing.T, path string) {
|
|
t.Helper()
|
|
|
|
_, err := os.Lstat(path)
|
|
if !errors.Is(err, fs.ErrNotExist) {
|
|
t.Errorf("%s: created, or Lstat failed: %v", path, err)
|
|
}
|
|
}
|
|
|
|
func requireOwner(t *testing.T, path string, uid, gid uint32) {
|
|
t.Helper()
|
|
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
stat, _ := info.Sys().(*syscall.Stat_t)
|
|
if stat.Uid != uid || stat.Gid != gid {
|
|
t.Errorf("%s: owner %d:%d, want %d:%d", path, stat.Uid, stat.Gid,
|
|
uid, gid)
|
|
}
|
|
}
|
|
|
|
func TestPrepareDataDirCreatesDataDir(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
volume := t.TempDir()
|
|
dir := filepath.Join(volume, "a", reports)
|
|
|
|
err := reportbuf.PrepareDataDir(volume, dir, currentUser())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
requireMode(t, volume, fs.ModeDir|0o750)
|
|
requireMode(t, dir, fs.ModeDir|0o750)
|
|
}
|
|
|
|
// TestPrepareDataDirSetsModeOfExistingDataDir: a DATA_DIR already on
|
|
// the host with another mode gets the mode too, not only a new one.
|
|
func TestPrepareDataDirSetsModeOfExistingDataDir(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
volume := t.TempDir()
|
|
dir := filepath.Join(volume, reports)
|
|
|
|
err := os.Mkdir(dir, 0o700)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = reportbuf.PrepareDataDir(volume, dir, currentUser())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
requireMode(t, dir, fs.ModeDir|0o750)
|
|
}
|
|
|
|
func TestPrepareDataDirTakesTheVolumeItself(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
volume := t.TempDir()
|
|
|
|
err := reportbuf.PrepareDataDir(volume, volume, currentUser())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
requireMode(t, volume, fs.ModeDir|0o750)
|
|
}
|
|
|
|
// TestPrepareDataDirRefusesDataDirOutsideVolume covers a DATA_DIR that
|
|
// is relative, outside the volume, or not written in full.
|
|
func TestPrepareDataDirRefusesDataDirOutsideVolume(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
volume := tempDirMode700(t)
|
|
for _, dir := range []string{
|
|
reports, volume + "/../new", volume + "/", volume + "//" + reports,
|
|
volume + "/./" + reports, volume + "/" + reports + "/..", volume + "x",
|
|
"/etc",
|
|
} {
|
|
err := reportbuf.PrepareDataDir(volume, dir, currentUser())
|
|
if !errors.Is(err, reportbuf.ErrDataDirOutsideVolume) {
|
|
t.Errorf("%q: error = %v, want ErrDataDirOutsideVolume", dir, err)
|
|
}
|
|
}
|
|
|
|
requireMissing(t, filepath.Join(filepath.Dir(volume), "new"))
|
|
requireMode(t, volume, fs.ModeDir|0o700)
|
|
}
|
|
|
|
// TestPrepareDataDirRefusesLinkOutOfVolume puts a symbolic link to a
|
|
// directory outside the volume on the path to DATA_DIR, written as a
|
|
// full path and as one that climbs out with '..', and as DATA_DIR
|
|
// itself, where the mode would be set through it.
|
|
func TestPrepareDataDirRefusesLinkOutOfVolume(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
climbsOut bool
|
|
link, dir string
|
|
}{
|
|
{"full path", false, "x", "x/reports"},
|
|
{"climbs out", true, "x", "x/reports"},
|
|
{"DATA_DIR itself", false, reports, reports},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
outside := tempDirMode700(t)
|
|
volume := t.TempDir()
|
|
|
|
climbOut, err := filepath.Rel(volume, outside)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
target := outside
|
|
if tc.climbsOut {
|
|
target = climbOut
|
|
}
|
|
|
|
err = os.Symlink(target, filepath.Join(volume, tc.link))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = reportbuf.PrepareDataDir(volume,
|
|
filepath.Join(volume, tc.dir), currentUser())
|
|
if err == nil {
|
|
t.Error("no error")
|
|
}
|
|
|
|
requireMissing(t, filepath.Join(outside, reports))
|
|
requireMode(t, outside, fs.ModeDir|0o700)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPrepareDataDirRefusesDanglingLink: DATA_DIR is a symbolic link
|
|
// to a name in the volume that does not exist, which is not created.
|
|
func TestPrepareDataDirRefusesDanglingLink(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
volume := t.TempDir()
|
|
|
|
err := os.Symlink("missing", filepath.Join(volume, reports))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = reportbuf.PrepareDataDir(volume, filepath.Join(volume, reports),
|
|
currentUser())
|
|
if err == nil {
|
|
t.Error("no error")
|
|
}
|
|
|
|
requireMissing(t, filepath.Join(volume, "missing"))
|
|
}
|
|
|
|
// TestPrepareDataDirTakesDirectoryNamedInLatin1: a host directory can
|
|
// hold names that are not valid UTF-8, here "café" written in Latin-1.
|
|
// A test not run as root can only check that PrepareDataDir goes into
|
|
// such a directory and gives it, and what it holds, to the current
|
|
// user.
|
|
func TestPrepareDataDirTakesDirectoryNamedInLatin1(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
volume := t.TempDir()
|
|
latin1 := filepath.Join(volume, "caf\xe9")
|
|
|
|
err := os.Mkdir(latin1, 0o700)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = os.WriteFile(filepath.Join(latin1, "f"), nil, 0o600)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
owner := currentUser()
|
|
|
|
err = reportbuf.PrepareDataDir(volume, filepath.Join(volume, reports),
|
|
owner)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
uid, _ := strconv.ParseUint(owner.Uid, 10, 32)
|
|
gid, _ := strconv.ParseUint(owner.Gid, 10, 32)
|
|
|
|
requireOwner(t, latin1, uint32(uid), uint32(gid))
|
|
requireOwner(t, filepath.Join(latin1, "f"), uint32(uid), uint32(gid))
|
|
}
|
|
|
|
// TestPrepareDataDirGivesVolumeToOwner gives the directories and
|
|
// regular files in the volume to a uid and gid that own nothing, which
|
|
// only root can do. A symbolic link in the volume to a directory
|
|
// outside it, and a hard link to a file outside it, are left as they
|
|
// were, and so is what they lead to.
|
|
func TestPrepareDataDirGivesVolumeToOwner(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if os.Geteuid() != 0 {
|
|
t.Skip("only root can give files to another uid")
|
|
}
|
|
|
|
outside := t.TempDir()
|
|
volume := t.TempDir()
|
|
old := filepath.Join(volume, "old")
|
|
|
|
err := os.WriteFile(filepath.Join(outside, "f"), nil, 0o600)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = os.Mkdir(old, 0o700)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = os.WriteFile(filepath.Join(old, "f"), nil, 0o600)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = os.Symlink(outside, filepath.Join(old, "link"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = os.Link(filepath.Join(outside, "f"), filepath.Join(old, "hardlink"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err = reportbuf.PrepareDataDir(volume, filepath.Join(volume, reports),
|
|
&user.User{Uid: "4242", Gid: "4343"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for _, path := range []string{
|
|
volume, filepath.Join(volume, reports), old, filepath.Join(old, "f"),
|
|
} {
|
|
requireOwner(t, path, 4242, 4343)
|
|
}
|
|
|
|
requireOwner(t, filepath.Join(old, "link"), 0, 0)
|
|
requireOwner(t, outside, 0, 0)
|
|
requireOwner(t, filepath.Join(outside, "f"), 0, 0)
|
|
}
|