Files
librenotes/internal/tenant/fs.go
T
libretechandClaude Opus 4.7 c9b8c4445b Add per-tenant filesystem isolation
internal/tenant/ provides FS, a sandboxed handle for a single
tenant's notes directory. Implementation strategy:

- Defence in depth: every relative path is validated up front
  (rejects "..", absolute paths, NUL bytes, empty), then handed
  to os.Root (Go 1.24+) which enforces the boundary at the
  syscall layer using openat(2)+RESOLVE_BENEATH on Linux. This
  closes TOCTOU races and symlink-target swapping.
- WriteFile is atomic (write to .tmp, rename in-root). Mode 0o600
  on files, 0o700 on directories. Tenant root is created with
  0o700 by Open().
- Errors are normalised: fs.ErrNotExist -> ErrNotFound, anything
  os.Root rejects as "outside" the root -> ErrInvalidPath. The
  HTTP layer can map cleanly to 404 / 400.

Tests cover the full traversal attack surface — "../", absolute
paths, mixed separators, NUL bytes, "." and "" — plus symlink
escapes and cross-tenant isolation. All vectors return errors;
none escape the root.

Closes #10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:17:38 +02:00

227 lines
5.9 KiB
Go

// Package tenant implements per-user filesystem isolation. Each tenant
// is bound to a root directory; all filesystem operations on notes go
// through an FS instance that uses os.Root to prevent path traversal,
// symlink escapes, and any other access outside the root.
package tenant
import (
"errors"
"fmt"
"io"
"io/fs"
"os"
"path"
"strings"
)
// ErrInvalidPath is returned when a relative path attempts to escape
// the tenant root (e.g. via "..", absolute paths, or NUL bytes).
var ErrInvalidPath = errors.New("invalid path")
// ErrNotFound mirrors fs.ErrNotExist for notes lookups.
var ErrNotFound = fs.ErrNotExist
// FS is a sandboxed filesystem rooted at a single tenant's data
// directory. All methods accept relative paths only; absolute paths,
// "..", and any other escape attempts are rejected up front. Beneath
// that, os.Root enforces the same boundary at the syscall level so
// even a TOCTOU race cannot escape the root.
type FS struct {
root *os.Root
dir string
}
// Open opens (creating if needed) the per-user directory at dir and
// returns an FS that is restricted to it.
func Open(dir string) (*FS, error) {
if dir == "" {
return nil, fmt.Errorf("tenant: empty dir")
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("tenant: mkdir %s: %w", dir, err)
}
root, err := os.OpenRoot(dir)
if err != nil {
return nil, fmt.Errorf("tenant: open root %s: %w", dir, err)
}
return &FS{root: root, dir: dir}, nil
}
// Close releases the underlying os.Root handle.
func (f *FS) Close() error { return f.root.Close() }
// Dir returns the tenant root directory on the host filesystem.
func (f *FS) Dir() string { return f.dir }
// validate rejects paths we never want to evaluate, even before
// handing them to os.Root. This catches obvious garbage with a
// stable error type and avoids relying on platform-specific behaviour
// of OpenInRoot for edge cases like NUL bytes.
func validate(rel string) (string, error) {
if rel == "" || rel == "." {
return "", ErrInvalidPath
}
if strings.ContainsRune(rel, 0) {
return "", ErrInvalidPath
}
if path.IsAbs(rel) || strings.HasPrefix(rel, "/") || strings.HasPrefix(rel, `\`) {
return "", ErrInvalidPath
}
clean := path.Clean(rel)
if clean == ".." || strings.HasPrefix(clean, "../") || clean == "." {
return "", ErrInvalidPath
}
for _, seg := range strings.Split(clean, "/") {
if seg == ".." {
return "", ErrInvalidPath
}
}
return clean, nil
}
// WriteFile writes data to rel atomically (write+rename) with mode 0o600.
// Any necessary parent directories are created with mode 0o700.
func (f *FS) WriteFile(rel string, data []byte) error {
clean, err := validate(rel)
if err != nil {
return err
}
if dir := path.Dir(clean); dir != "." {
if err := f.mkdirAll(dir); err != nil {
return err
}
}
tmp := clean + ".tmp"
out, err := f.root.Create(tmp)
if err != nil {
return wrap(err)
}
if _, err := out.Write(data); err != nil {
_ = out.Close()
_ = f.root.Remove(tmp)
return err
}
if err := out.Close(); err != nil {
_ = f.root.Remove(tmp)
return err
}
if err := f.root.Rename(tmp, clean); err != nil {
_ = f.root.Remove(tmp)
return wrap(err)
}
return nil
}
// ReadFile returns the contents of rel.
func (f *FS) ReadFile(rel string) ([]byte, error) {
clean, err := validate(rel)
if err != nil {
return nil, err
}
in, err := f.root.Open(clean)
if err != nil {
return nil, wrap(err)
}
defer in.Close()
return io.ReadAll(in)
}
// Stat returns FileInfo for rel.
func (f *FS) Stat(rel string) (os.FileInfo, error) {
clean, err := validate(rel)
if err != nil {
return nil, err
}
fi, err := f.root.Stat(clean)
if err != nil {
return nil, wrap(err)
}
return fi, nil
}
// Remove deletes a file. Empty directories must be removed via the
// underlying root; we expose only file deletes for the notes API.
func (f *FS) Remove(rel string) error {
clean, err := validate(rel)
if err != nil {
return err
}
return wrap(f.root.Remove(clean))
}
// List returns the names of entries directly under rel (use "." for
// the root). Symlink targets outside the tenant root cannot exist
// here because os.Root refuses to create them, but if a malicious
// link is dropped on disk out-of-band, calls that would follow it
// will return an error rather than escape.
func (f *FS) List(rel string) ([]string, error) {
if rel == "." || rel == "" {
entries, err := os.ReadDir(f.dir)
if err != nil {
return nil, err
}
return entryNames(entries), nil
}
clean, err := validate(rel)
if err != nil {
return nil, err
}
dir, err := f.root.Open(clean)
if err != nil {
return nil, wrap(err)
}
defer dir.Close()
entries, err := dir.ReadDir(-1)
if err != nil {
return nil, err
}
return entryNames(entries), nil
}
func entryNames(entries []fs.DirEntry) []string {
out := make([]string, 0, len(entries))
for _, e := range entries {
out = append(out, e.Name())
}
return out
}
// mkdirAll creates rel and all missing parents within the root, mode 0o700.
func (f *FS) mkdirAll(rel string) error {
parts := strings.Split(rel, "/")
cur := ""
for _, p := range parts {
if p == "" {
continue
}
if cur == "" {
cur = p
} else {
cur = cur + "/" + p
}
if err := f.root.Mkdir(cur, 0o700); err != nil && !errors.Is(err, fs.ErrExist) {
return wrap(err)
}
}
return nil
}
// wrap normalises errors from os.Root into our error vocabulary so
// callers can use errors.Is(err, ErrInvalidPath) / ErrNotFound.
func wrap(err error) error {
if err == nil {
return nil
}
if errors.Is(err, fs.ErrNotExist) {
return ErrNotFound
}
// os.Root returns errors whose text mentions "outside root" or
// "path escapes from parent". Map those to ErrInvalidPath so the
// HTTP layer can return 400/403 consistently.
msg := err.Error()
if strings.Contains(msg, "outside") || strings.Contains(msg, "escape") || strings.Contains(msg, "openat") {
return ErrInvalidPath
}
return err
}