diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-19 21:51:44 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-19 21:51:44 +0200 |
| commit | 2ab8a24c188a2ba39424eb7925bc7ff3fb767bfb (patch) | |
| tree | 0867a5d189d61a6e7f6ce4accea9868014a0fe7d /internal/io | |
| parent | 91296d85e8a6f1aca5beaeeecf648683c83c75bc (diff) | |
task 261: harden server reads with OpenRoot
Diffstat (limited to 'internal/io')
| -rw-r--r-- | internal/io/fs/catfile.go | 9 | ||||
| -rw-r--r-- | internal/io/fs/readfile.go | 13 | ||||
| -rw-r--r-- | internal/io/fs/tailfile.go | 9 | ||||
| -rw-r--r-- | internal/io/fs/validatedreadtarget.go | 90 | ||||
| -rw-r--r-- | internal/io/fs/validatedreadtarget_test.go | 166 |
5 files changed, 285 insertions, 2 deletions
diff --git a/internal/io/fs/catfile.go b/internal/io/fs/catfile.go index 1f35a95..ac42fc0 100644 --- a/internal/io/fs/catfile.go +++ b/internal/io/fs/catfile.go @@ -21,3 +21,12 @@ func NewCatFile(filePath string, globID string, serverMessages chan<- string, }, } } + +// NewValidatedCatFile returns a new file catter backed by a rooted open target. +func NewValidatedCatFile(filePath string, target ValidatedReadTarget, globID string, + serverMessages chan<- string, maxLineLength int) CatFile { + + cat := NewCatFile(filePath, globID, serverMessages, maxLineLength) + cat.readFile.validatedTarget = &target + return cat +} diff --git a/internal/io/fs/readfile.go b/internal/io/fs/readfile.go index 0ec2eca..d305c4d 100644 --- a/internal/io/fs/readfile.go +++ b/internal/io/fs/readfile.go @@ -38,6 +38,8 @@ type readFile struct { stats // Path of log file to tail. filePath string + // Rooted target used for validated server-side re-opens. + validatedTarget *ValidatedReadTarget // The glob identifier of the file. globID string // Channel to send a server message to the dtail client @@ -134,7 +136,7 @@ func (f *readFile) makeReader() (*bufio.Reader, *os.File, io.Closer, error) { } func (f *readFile) makeFileReader() (reader *bufio.Reader, fd *os.File, decompressor io.Closer, err error) { - if fd, err = os.Open(f.filePath); err != nil { + if fd, err = f.openFile(); err != nil { return } @@ -148,6 +150,13 @@ func (f *readFile) makeFileReader() (reader *bufio.Reader, fd *os.File, decompre return } +func (f *readFile) openFile() (*os.File, error) { + if f.validatedTarget != nil { + return f.validatedTarget.Open() + } + return os.Open(f.filePath) +} + func (f *readFile) makePipeReader() (*bufio.Reader, *os.File, io.Closer, error) { return bufio.NewReader(os.Stdin), nil, nil, nil } @@ -276,7 +285,7 @@ func (f *readFile) truncated(fd *os.File) (bool, error) { return true, err } // Can not open file at original path. - pathFd, err := os.Open(f.filePath) + pathFd, err := f.openFile() if err != nil { return true, err } diff --git a/internal/io/fs/tailfile.go b/internal/io/fs/tailfile.go index b2e9910..a5f172d 100644 --- a/internal/io/fs/tailfile.go +++ b/internal/io/fs/tailfile.go @@ -21,3 +21,12 @@ func NewTailFile(filePath string, globID string, serverMessages chan<- string, }, } } + +// NewValidatedTailFile returns a new file tailer backed by a rooted open target. +func NewValidatedTailFile(filePath string, target ValidatedReadTarget, globID string, + serverMessages chan<- string, maxLineLength int) TailFile { + + tail := NewTailFile(filePath, globID, serverMessages, maxLineLength) + tail.readFile.validatedTarget = &target + return tail +} diff --git a/internal/io/fs/validatedreadtarget.go b/internal/io/fs/validatedreadtarget.go new file mode 100644 index 0000000..83b1404 --- /dev/null +++ b/internal/io/fs/validatedreadtarget.go @@ -0,0 +1,90 @@ +package fs + +import ( + "fmt" + "os" + "path/filepath" +) + +// ValidatedReadTarget stores a resolved regular file path for rooted re-opens. +type ValidatedReadTarget struct { + resolvedPath string + rootDir string + rootName string +} + +// NewValidatedReadTarget returns a rooted target for a resolved regular file. +func NewValidatedReadTarget(resolvedPath string) (ValidatedReadTarget, error) { + cleanedPath := filepath.Clean(resolvedPath) + if !filepath.IsAbs(cleanedPath) { + return ValidatedReadTarget{}, fmt.Errorf("validated read target requires absolute path: %s", cleanedPath) + } + + info, err := os.Lstat(cleanedPath) + if err != nil { + return ValidatedReadTarget{}, fmt.Errorf("lstat validated read target %s: %w", cleanedPath, err) + } + if !info.Mode().IsRegular() { + return ValidatedReadTarget{}, fmt.Errorf("validated read target must be a regular file: %s", cleanedPath) + } + + return ValidatedReadTarget{ + resolvedPath: cleanedPath, + rootDir: filepath.Dir(cleanedPath), + rootName: filepath.Base(cleanedPath), + }, nil +} + +// Open re-opens the validated file beneath its resolved parent directory. +func (t ValidatedReadTarget) Open() (*os.File, error) { + root, err := os.OpenRoot(t.rootDir) + if err != nil { + return nil, fmt.Errorf("open root for %s: %w", t.resolvedPath, err) + } + defer root.Close() + + if err := t.validateEntry(root); err != nil { + return nil, err + } + + fd, err := root.Open(t.rootName) + if err != nil { + return nil, fmt.Errorf("open rooted file %s: %w", t.resolvedPath, err) + } + + if err := validateOpenedFile(fd, t.resolvedPath); err != nil { + fd.Close() + return nil, err + } + if err := t.validateEntry(root); err != nil { + fd.Close() + return nil, err + } + + return fd, nil +} + +func (t ValidatedReadTarget) validateEntry(root *os.Root) error { + info, err := root.Lstat(t.rootName) + if err != nil { + return fmt.Errorf("lstat rooted file %s: %w", t.resolvedPath, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("rooted file changed to symlink: %s", t.resolvedPath) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("rooted file changed to non-regular file: %s", t.resolvedPath) + } + return nil +} + +func validateOpenedFile(fd *os.File, resolvedPath string) error { + info, err := fd.Stat() + if err != nil { + return fmt.Errorf("stat opened file %s: %w", resolvedPath, err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("opened file is not regular: %s", resolvedPath) + } + return nil +} diff --git a/internal/io/fs/validatedreadtarget_test.go b/internal/io/fs/validatedreadtarget_test.go new file mode 100644 index 0000000..b256f20 --- /dev/null +++ b/internal/io/fs/validatedreadtarget_test.go @@ -0,0 +1,166 @@ +package fs + +import ( + "context" + "io" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/mimecast/dtail/internal/io/dlog" + "github.com/mimecast/dtail/internal/lcontext" + "github.com/mimecast/dtail/internal/regex" +) + +func TestValidatedCatFileStartWithProcessorOptimizedReadsAllLines(t *testing.T) { + resetCommonLogger(t) + + filePath := writeProcessorTestFile(t, "alpha\nbeta\n") + target := mustValidatedReadTarget(t, filePath) + re := regex.NewNoop() + + cat := NewValidatedCatFile(filePath, target, "glob-id", make(chan string, 1), defaultMaxLineLength) + processor := &captureProcessor{} + + if err := cat.readFile.StartWithProcessorOptimized( + context.Background(), + lcontext.LContext{}, + processor, + re, + ); err != nil { + t.Fatalf("validated optimized reader start failed: %v", err) + } + + want := []string{"alpha\n", "beta\n"} + if !reflect.DeepEqual(processor.lines, want) { + t.Fatalf("unexpected processed lines: got=%v want=%v", processor.lines, want) + } +} + +func TestValidatedReadTargetOpenRejectsEscapingSymlinkSwap(t *testing.T) { + resetCommonLogger(t) + + baseDir := t.TempDir() + rootDir := filepath.Join(baseDir, "root") + outsideDir := filepath.Join(baseDir, "outside") + if err := os.MkdirAll(rootDir, 0755); err != nil { + t.Fatalf("mkdir root dir: %v", err) + } + if err := os.MkdirAll(outsideDir, 0755); err != nil { + t.Fatalf("mkdir outside dir: %v", err) + } + + filePath := filepath.Join(rootDir, "app.log") + if err := os.WriteFile(filePath, []byte("alpha\n"), 0600); err != nil { + t.Fatalf("write app log: %v", err) + } + escapePath := filepath.Join(outsideDir, "secret.log") + if err := os.WriteFile(escapePath, []byte("secret\n"), 0600); err != nil { + t.Fatalf("write secret log: %v", err) + } + + target := mustValidatedReadTarget(t, filePath) + + if err := os.Remove(filePath); err != nil { + t.Fatalf("remove app log: %v", err) + } + relativeEscape, err := filepath.Rel(rootDir, escapePath) + if err != nil { + t.Fatalf("relative escape path: %v", err) + } + if err := os.Symlink(relativeEscape, filePath); err != nil { + t.Fatalf("symlink escape path: %v", err) + } + + if _, err := target.Open(); err == nil { + t.Fatal("expected rooted open to reject escaping symlink swap") + } +} + +func TestValidatedReadTargetOpenRejectsSameRootSymlinkSwap(t *testing.T) { + resetCommonLogger(t) + + baseDir := t.TempDir() + filePath := filepath.Join(baseDir, "app.log") + if err := os.WriteFile(filePath, []byte("alpha\n"), 0600); err != nil { + t.Fatalf("write app log: %v", err) + } + otherPath := filepath.Join(baseDir, "other.log") + if err := os.WriteFile(otherPath, []byte("other\n"), 0600); err != nil { + t.Fatalf("write other log: %v", err) + } + + target := mustValidatedReadTarget(t, filePath) + + if err := os.Remove(filePath); err != nil { + t.Fatalf("remove app log: %v", err) + } + if err := os.Symlink(filepath.Base(otherPath), filePath); err != nil { + t.Fatalf("symlink other log: %v", err) + } + + _, err := target.Open() + if err == nil { + t.Fatal("expected rooted open to reject same-root symlink swap") + } + if !strings.Contains(err.Error(), "symlink") { + t.Fatalf("expected symlink rejection, got %v", err) + } +} + +func TestValidatedTailFileTruncatedReopenDetectsTruncation(t *testing.T) { + resetCommonLogger(t) + + filePath := writeProcessorTestFile(t, "alpha\nbeta\n") + target := mustValidatedReadTarget(t, filePath) + + tail := NewValidatedTailFile(filePath, target, "glob-id", make(chan string, 1), defaultMaxLineLength) + fd, err := target.Open() + if err != nil { + t.Fatalf("open validated target: %v", err) + } + defer fd.Close() + + if _, err := fd.Seek(0, io.SeekEnd); err != nil { + t.Fatalf("seek end: %v", err) + } + if err := os.Truncate(filePath, 1); err != nil { + t.Fatalf("truncate file: %v", err) + } + + isTruncated, err := tail.readFile.truncated(fd) + if !isTruncated { + t.Fatal("expected truncation to be detected") + } + if err == nil || !strings.Contains(err.Error(), "truncated") { + t.Fatalf("expected truncation error, got %v", err) + } +} + +func mustValidatedReadTarget(t *testing.T, path string) ValidatedReadTarget { + t.Helper() + + absolutePath, err := filepath.Abs(path) + if err != nil { + t.Fatalf("abs path: %v", err) + } + + target, err := NewValidatedReadTarget(absolutePath) + if err != nil { + t.Fatalf("create validated target: %v", err) + } + + return target +} + +func resetCommonLogger(t *testing.T) { + t.Helper() + + originalLogger := dlog.Common + dlog.Common = &dlog.DLog{} + t.Cleanup(func() { + dlog.Common = originalLogger + }) +} |
