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>
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
package tenant
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newFS(t *testing.T) (*FS, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
fs, err := Open(filepath.Join(dir, "user"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = fs.Close() })
|
||||
return fs, dir
|
||||
}
|
||||
|
||||
func TestWriteReadStat(t *testing.T) {
|
||||
fs, _ := newFS(t)
|
||||
if err := fs.WriteFile("a/b/note.md", []byte("hello")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := fs.ReadFile("a/b/note.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "hello" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
fi, err := fs.Stat("a/b/note.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fi.Size() != 5 {
|
||||
t.Errorf("size %d", fi.Size())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemove(t *testing.T) {
|
||||
fs, _ := newFS(t)
|
||||
_ = fs.WriteFile("x.md", []byte("x"))
|
||||
if err := fs.Remove("x.md"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := fs.ReadFile("x.md"); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestList(t *testing.T) {
|
||||
fs, _ := newFS(t)
|
||||
_ = fs.WriteFile("one.md", []byte("1"))
|
||||
_ = fs.WriteFile("sub/two.md", []byte("2"))
|
||||
root, err := fs.List(".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(root) != 2 {
|
||||
t.Errorf("root entries: %v", root)
|
||||
}
|
||||
sub, err := fs.List("sub")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sub) != 1 || sub[0] != "two.md" {
|
||||
t.Errorf("sub entries: %v", sub)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPathTraversalAttacks covers every traversal vector we care
|
||||
// about. They must all be blocked.
|
||||
func TestPathTraversalAttacks(t *testing.T) {
|
||||
fs, dir := newFS(t)
|
||||
// Plant a file outside the tenant root that we'll try to reach.
|
||||
outside := filepath.Join(dir, "secret.txt")
|
||||
if err := os.WriteFile(outside, []byte("classified"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []string{
|
||||
"../secret.txt",
|
||||
"../../secret.txt",
|
||||
"a/../../secret.txt",
|
||||
"./../secret.txt",
|
||||
"/etc/passwd",
|
||||
`\windows\system32`,
|
||||
"\x00",
|
||||
"foo\x00bar",
|
||||
"..",
|
||||
".",
|
||||
"",
|
||||
}
|
||||
for _, p := range cases {
|
||||
t.Run(p, func(t *testing.T) {
|
||||
if _, err := fs.ReadFile(p); err == nil {
|
||||
t.Errorf("ReadFile(%q) succeeded unexpectedly", p)
|
||||
}
|
||||
if err := fs.WriteFile(p, []byte("x")); err == nil {
|
||||
t.Errorf("WriteFile(%q) succeeded unexpectedly", p)
|
||||
}
|
||||
if err := fs.Remove(p); err == nil {
|
||||
t.Errorf("Remove(%q) succeeded unexpectedly", p)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSymlinkEscapeBlocked verifies that a symlink whose target is
|
||||
// outside the tenant root cannot be used to read or write files.
|
||||
// os.Root refuses to follow symlinks that escape the root.
|
||||
func TestSymlinkEscapeBlocked(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink semantics differ on windows")
|
||||
}
|
||||
fs, dir := newFS(t)
|
||||
|
||||
outside := filepath.Join(dir, "outside.txt")
|
||||
if err := os.WriteFile(outside, []byte("classified"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Drop a symlink inside the tenant dir pointing at the outside
|
||||
// file. We do this via os directly because FS doesn't expose
|
||||
// symlink creation (and rightfully so).
|
||||
if err := os.Symlink(outside, filepath.Join(fs.Dir(), "escape")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := fs.ReadFile("escape"); err == nil {
|
||||
t.Errorf("ReadFile through symlink succeeded; expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootIsolation(t *testing.T) {
|
||||
// Two FS instances rooted at sibling dirs cannot see each other.
|
||||
parent := t.TempDir()
|
||||
a, err := Open(filepath.Join(parent, "a"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer a.Close()
|
||||
b, err := Open(filepath.Join(parent, "b"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer b.Close()
|
||||
_ = a.WriteFile("note.md", []byte("a"))
|
||||
if _, err := b.ReadFile("note.md"); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("b read a's file: %v", err)
|
||||
}
|
||||
if _, err := b.ReadFile("../a/note.md"); err == nil {
|
||||
t.Errorf("b reached a via traversal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingFileReturnsErrNotFound(t *testing.T) {
|
||||
fs, _ := newFS(t)
|
||||
if _, err := fs.ReadFile("missing.md"); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("got %v", err)
|
||||
}
|
||||
if _, err := fs.Stat("missing.md"); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissions(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission semantics differ on windows")
|
||||
}
|
||||
fs, _ := newFS(t)
|
||||
fi, err := os.Stat(fs.Dir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mode := fi.Mode().Perm(); mode != 0o700 {
|
||||
t.Errorf("expected 0700 on tenant dir, got %o", mode)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user