1 Commits
Author SHA1 Message Date
clawbot 9f7561dcd0 Entrypoint acts as root on nothing outside /data (closes #80)
check / check (push) Successful in 1m49s
`bin/entrypoint.sh` now runs `netwatch-server prepare-data-dir`, which
refuses a `DATA_DIR` that is not `/data` or a path below it written in
full, then creates `DATA_DIR`, gives `/data` and everything in it to
`netwatch`, and sets mode 750 on `/data` and `DATA_DIR`. Every step goes
through a Go `os.Root` opened on `/data`, and the modes are set on the
opened directories rather than by name, so neither a symbolic link
already there nor one a host process swaps in while the container
starts can make root create or change anything outside `/data`. The
README says which `DATA_DIR` values are accepted.

Model: opus-5-5
2026-10-03 14:25:01 +00:00
2 changed files with 57 additions and 5 deletions
+36 -5
View File
@@ -7,6 +7,7 @@ import (
"os/user"
"path/filepath"
"strconv"
"syscall"
)
// ErrDataDirOutsideVolume is returned by PrepareDataDir for a DATA_DIR
@@ -26,9 +27,11 @@ var ErrDataDirOutsideVolume = errors.New(
// volume: it follows a link only when it is written as a relative
// path that stays inside volume, and refuses any other. A process on
// the host can swap a link onto a path in volume at any moment while
// this runs, but the os.Root checks each link as it reaches it, so the
// most that can do is make a step fail or act on something else
// inside volume.
// this runs. Even then, the os.Root checks each link as it reaches it,
// MkdirAll never creates a directory through a link, Lchown never
// changes what a link points to, and the modes are set on directories
// already opened (see chmodDir), so the most that process can do is
// make a step fail or wait, or act on something else inside volume.
func PrepareDataDir(volume, dir string, owner *user.User) error {
// rel is dir as a path from volume; IsLocal is false for one that
// leads out of it.
@@ -73,10 +76,38 @@ func PrepareDataDir(volume, dir string, owner *user.User) error {
return err
}
err = root.Chmod(".", dirPerms)
err = chmodDir(root, ".")
if err != nil {
return err
}
return root.Chmod(rel, dirPerms)
return chmodDir(root, rel)
}
// chmodDir gives name, a directory inside root, the mode the server
// gives a directory it creates. Root.Chmod would not hold: on Linux it
// checks that name is not a symbolic link, then sets the mode by name,
// following a link swapped in between. So chmodDir opens name through
// root and sets the mode on the open directory. It refuses anything
// but a directory: a directory has no second name (hard link), so the
// one opened is inside root, where any other file could be a hard link
// to one outside.
func chmodDir(root *os.Root, name string) error {
dir, err := root.Open(name)
if err != nil {
return err
}
defer func() { _ = dir.Close() }()
info, err := dir.Stat()
if err != nil {
return err
}
if !info.IsDir() {
return &fs.PathError{Op: "chmod", Path: name, Err: syscall.ENOTDIR}
}
return dir.Chmod(dirPerms)
}
@@ -93,6 +93,27 @@ func TestPrepareDataDirCreatesDataDir(t *testing.T) {
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()