diff --git a/cmd/tailscale/cli/drive.go b/cmd/tailscale/cli/drive.go index 280ff3172..77d57235f 100644 --- a/cmd/tailscale/cli/drive.go +++ b/cmd/tailscale/cli/drive.go @@ -8,11 +8,13 @@ package cli import ( "context" "fmt" + "os" "path/filepath" "strings" "github.com/peterbourgon/ff/v3/ffcli" "tailscale.com/drive" + "tailscale.com/feature/buildfeatures" ) const ( @@ -80,10 +82,27 @@ func runDriveShare(ctx context.Context, args []string) error { return err } - err = localClient.DriveShareSet(ctx, &drive.Share{ + share := &drive.Share{ Name: name, Path: absolutePath, - }) + } + + // "magic" is a reserved share name (drive.MagicShareName). Magic shares + // are treated specially server-side: each top-level directory inside the + // share encodes its own ACL (see package drive/magic). The path must + // already exist and be a directory; for non-magic shares we leave that + // check to the backend. + if buildfeatures.HasDriveMagic && share.IsMagic() { + fi, err := os.Stat(absolutePath) + if err != nil { + return fmt.Errorf("magic share path: %w", err) + } + if !fi.IsDir() { + return fmt.Errorf("magic share path %q is not a directory", absolutePath) + } + } + + err = localClient.DriveShareSet(ctx, share) if err == nil { fmt.Printf("Sharing %q as %q\n", path, name) } @@ -144,11 +163,19 @@ func runDriveList(ctx context.Context, args []string) error { longestAs = len(share.As) } } - formatString := fmt.Sprintf("%%-%ds %%-%ds %%s\n", longestName, longestPath) - fmt.Printf(formatString, "name", "path", "as") - fmt.Printf(formatString, strings.Repeat("-", longestName), strings.Repeat("-", longestPath), strings.Repeat("-", longestAs)) + formatString := fmt.Sprintf("%%-%ds %%-%ds %%-%ds %%s\n", longestName, longestPath, longestAs) + fmt.Printf(formatString, "name", "path", "as", "type") + fmt.Printf(formatString, + strings.Repeat("-", longestName), + strings.Repeat("-", longestPath), + strings.Repeat("-", longestAs), + "----") for _, share := range shares { - fmt.Printf(formatString, share.Name, share.Path, share.As) + typ := "normal" + if buildfeatures.HasDriveMagic && share.IsMagic() { + typ = "magic" + } + fmt.Printf(formatString, share.Name, share.Path, share.As, typ) } return nil diff --git a/cmd/tailscaled/deps_test.go b/cmd/tailscaled/deps_test.go index be4f65a7d..d5c9edf48 100644 --- a/cmd/tailscaled/deps_test.go +++ b/cmd/tailscaled/deps_test.go @@ -93,6 +93,17 @@ func TestOmitDrive(t *testing.T) { }.Check(t) } +func TestOmitDriveMagic(t *testing.T) { + deptest.DepChecker{ + GOOS: "linux", + GOARCH: "amd64", + Tags: "ts_omit_drive_magic,ts_include_cli", + BadDeps: map[string]string{ + "tailscale.com/drive/magic": "unexpected dep with ts_omit_drive_magic", + }, + }.Check(t) +} + func TestOmitPortmapper(t *testing.T) { deptest.DepChecker{ GOOS: "linux", diff --git a/drive/driveimpl/drive_test.go b/drive/driveimpl/drive_test.go index 8f9b43d6b..ae006a0c0 100644 --- a/drive/driveimpl/drive_test.go +++ b/drive/driveimpl/drive_test.go @@ -438,6 +438,8 @@ type remote struct { fileServer *FileServer shares map[string]string permissions map[string]drive.Permission + peerLogin string + sharerLogin string mu sync.RWMutex } @@ -452,7 +454,11 @@ func (r *remote) unfreeze() { func (r *remote) ServeHTTP(w http.ResponseWriter, req *http.Request) { r.mu.RLock() defer r.mu.RUnlock() - r.fs.ServeHTTPWithPerms(r.permissions, w, req) + r.fs.ServeHTTPWithPerms(drive.Authz{ + PeerLogin: r.peerLogin, + SharerLogin: r.sharerLogin, + Permissions: r.permissions, + }, w, req) } type system struct { @@ -745,7 +751,7 @@ func pathTo(remote, share, name string) string { type noopAuthorizer struct{} func (a *noopAuthorizer) NewAuthenticator(body io.Reader) (gowebdav.Authenticator, io.Reader) { - return &noopAuthenticator{}, nil + return &noopAuthenticator{}, body } func (a *noopAuthorizer) AddAuthenticator(key string, fn gowebdav.AuthFactory) { diff --git a/drive/driveimpl/magic_test.go b/drive/driveimpl/magic_test.go new file mode 100644 index 000000000..6835a5b38 --- /dev/null +++ b/drive/driveimpl/magic_test.go @@ -0,0 +1,220 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !ts_omit_drive_magic + +package driveimpl + +import ( + "os" + "path" + "path/filepath" + "slices" + "sort" + "testing" + + "tailscale.com/drive" +) + +// addMagicShare configures the named remote to expose a magic share. +// The share's on-disk path is returned so the caller can prepopulate +// ACL directories. +func (s *system) addMagicShare(remoteName, shareName, peerLogin, sharerLogin string) string { + r, ok := s.remotes[remoteName] + if !ok { + s.t.Fatalf("unknown remote %q", remoteName) + } + + folder := s.t.TempDir() + r.shares[shareName] = folder + r.permissions[shareName] = drive.PermissionReadWrite + r.peerLogin = peerLogin + r.sharerLogin = sharerLogin + + shares := make([]*drive.Share, 0, len(r.shares)) + for n, f := range r.shares { + shares = append(shares, &drive.Share{Name: n, Path: f}) + } + slices.SortFunc(shares, drive.CompareShares) + r.fs.SetShares(shares) + r.fileServer.SetShares(r.shares) + return folder +} + +// setPeer rebinds the peer/sharer login for the given remote. The remote's +// ServeHTTP picks up these values on each request, so this works for tests +// that simulate different peer identities against the same sharer. +func (s *system) setPeer(remoteName, peerLogin, sharerLogin string) { + r, ok := s.remotes[remoteName] + if !ok { + s.t.Fatalf("unknown remote %q", remoteName) + } + r.mu.Lock() + r.peerLogin = peerLogin + r.sharerLogin = sharerLogin + r.mu.Unlock() +} + +func mkdir(t *testing.T, parent, name string) { + t.Helper() + if err := os.Mkdir(filepath.Join(parent, name), 0755); err != nil { + t.Fatalf("mkdir %q: %v", name, err) + } +} + +func TestMagicShareDiscoveryAndListing(t *testing.T) { + const ( + magicShare = "magic" + sharerLogin = "fserb@example.com" + peerRhea = "rhea@example.com" + peerJoe = "joe@example.com" + peerStranger = "stranger@example.com" + dirSharerOnly = "fserb" + dirSharerRhea = "fserb+rhea" + dirNoSharer = "rhea+joe" // invalid: no sharer + dirGarbage = "not a name" // invalid grammar + ) + + s := newSystem(t) + s.addRemote(remote1) + folder := s.addMagicShare(remote1, magicShare, peerRhea, sharerLogin) + + mkdir(t, folder, dirSharerOnly) + mkdir(t, folder, dirSharerRhea) + mkdir(t, folder, dirNoSharer) + mkdir(t, folder, dirGarbage) + + // rhea sees only the dirs that name both sharer and rhea. + checkMagicListing(t, s, magicShare, []string{dirSharerRhea}) + + // joe is matched by no dir. + s.setPeer(remote1, peerJoe, sharerLogin) + checkMagicListing(t, s, magicShare, nil) + + // fserb on another node sees their own dirs. + s.setPeer(remote1, sharerLogin, sharerLogin) + checkMagicListing(t, s, magicShare, []string{dirSharerOnly, dirSharerRhea}) + + // A peer not in any dir, but with the share grant, gets an empty + // listing (not 404). + s.setPeer(remote1, peerStranger, sharerLogin) + checkMagicListing(t, s, magicShare, nil) +} + +func TestMagicShareReadWrite(t *testing.T) { + const ( + magicShare = "magic" + sharerLogin = "fserb@example.com" + peerRhea = "rhea@example.com" + peerJoe = "joe@example.com" + dirShared = "fserb+rhea" + dirOther = "fserb" + dirInvalid = "rhea+joe" + fname = "note.txt" + ) + + s := newSystem(t) + s.addRemote(remote1) + folder := s.addMagicShare(remote1, magicShare, peerRhea, sharerLogin) + + mkdir(t, folder, dirShared) + mkdir(t, folder, dirOther) + mkdir(t, folder, dirInvalid) + + // rhea can read/write inside dirShared. + rheaPath := path.Join(domain, remote1, magicShare, dirShared, fname) + if err := s.client.Write(rheaPath, []byte("hi from rhea"), 0644); err != nil { + t.Fatalf("rhea write into %q: %v", dirShared, err) + } + // Verify it actually landed on disk under the share folder. + onDisk := filepath.Join(folder, dirShared, fname) + if b, err := os.ReadFile(onDisk); err != nil { + t.Fatalf("on-disk read after write: %v (file %s)", err, onDisk) + } else if string(b) != "hi from rhea" { + t.Fatalf("on-disk content %q != written %q", b, "hi from rhea") + } + got, err := s.client.Read(rheaPath) + if err != nil { + t.Fatalf("rhea read from %q: %v", dirShared, err) + } + if string(got) != "hi from rhea" { + t.Errorf("rhea read got %q, want %q", got, "hi from rhea") + } + + // rhea cannot write into a dir she's not in. + otherPath := path.Join(domain, remote1, magicShare, dirOther, fname) + if err := s.client.Write(otherPath, []byte("nope"), 0644); err == nil { + t.Errorf("rhea write into %q should have failed", dirOther) + } + + // rhea cannot read from a dir she's not in. + if _, err := s.client.Read(otherPath); err == nil { + t.Errorf("rhea read from %q should have failed", dirOther) + } + + // dirInvalid is invalid (no sharer in name): nobody, including the + // principals named, can access it. + s.setPeer(remote1, peerJoe, sharerLogin) + invalidPath := path.Join(domain, remote1, magicShare, dirInvalid, fname) + if err := s.client.Write(invalidPath, []byte("x"), 0644); err == nil { + t.Errorf("joe write into invalid dir %q should have failed", dirInvalid) + } + + // joe also cannot list anything (no matching dirs). + checkMagicListing(t, s, magicShare, nil) +} + +func TestMagicShareTopLevelMutationsDenied(t *testing.T) { + const ( + magicShare = "magic" + sharerLogin = "fserb@example.com" + peerRhea = "rhea@example.com" + dirShared = "fserb+rhea" + ) + + s := newSystem(t) + s.addRemote(remote1) + folder := s.addMagicShare(remote1, magicShare, peerRhea, sharerLogin) + mkdir(t, folder, dirShared) + + // MKCOL of a brand new top-level dir name must fail. + newDir := path.Join(domain, remote1, magicShare, "fserb+rhea+joe") + if err := s.client.Mkdir(newDir, 0755); err == nil { + t.Errorf("mkcol of new top-level dir should have been denied") + } + + // DELETE of an existing top-level dir must fail. + existingDir := path.Join(domain, remote1, magicShare, dirShared) + if err := s.client.RemoveAll(existingDir); err == nil { + t.Errorf("delete of existing top-level dir should have been denied") + } + + // But files inside the dir can be deleted normally. + filePath := path.Join(existingDir, "f.txt") + if err := s.client.Write(filePath, []byte("hi"), 0644); err != nil { + t.Fatalf("write inside acl dir: %v", err) + } + if err := s.client.Remove(filePath); err != nil { + t.Errorf("delete inside acl dir failed: %v", err) + } +} + +// checkMagicListing verifies that a depth-1 listing of //// +// returns exactly want (in any order). +func checkMagicListing(t *testing.T, s *system, magicShare string, want []string) { + t.Helper() + entries, err := s.client.ReadDir(path.Join(domain, remote1, magicShare)) + if err != nil { + t.Fatalf("readdir: %v", err) + } + got := make([]string, 0, len(entries)) + for _, e := range entries { + got = append(got, e.Name()) + } + sort.Strings(got) + sortedWant := append([]string(nil), want...) + sort.Strings(sortedWant) + if !slices.Equal(got, sortedWant) { + t.Errorf("listing got %v, want %v", got, sortedWant) + } +} diff --git a/drive/driveimpl/remote_impl.go b/drive/driveimpl/remote_impl.go index 0ff27dc64..ea5e75f42 100644 --- a/drive/driveimpl/remote_impl.go +++ b/drive/driveimpl/remote_impl.go @@ -27,6 +27,7 @@ import ( "tailscale.com/drive/driveimpl/compositedav" "tailscale.com/drive/driveimpl/dirfs" "tailscale.com/drive/driveimpl/shared" + "tailscale.com/feature/buildfeatures" "tailscale.com/safesocket" "tailscale.com/types/logger" ) @@ -199,11 +200,14 @@ func (s *FileSystemForRemote) buildChild(share *drive.Share) *compositedav.Child } // ServeHTTPWithPerms implements drive.FileSystemForRemote. -func (s *FileSystemForRemote) ServeHTTPWithPerms(permissions drive.Permissions, w http.ResponseWriter, r *http.Request) { +func (s *FileSystemForRemote) ServeHTTPWithPerms(authz drive.Authz, w http.ResponseWriter, r *http.Request) { + permissions := authz.Permissions + pathComponents := shared.CleanAndSplit(r.URL.Path) + shareName := pathComponents[0] + isWrite := writeMethods[r.Method] if isWrite { - share := shared.CleanAndSplit(r.URL.Path)[0] - switch permissions.For(share) { + switch permissions.For(shareName) { case drive.PermissionNone: // If we have no permissions to this share, treat it as not found // to avoid leaking any information about the share's existence. @@ -216,16 +220,32 @@ func (s *FileSystemForRemote) ServeHTTPWithPerms(permissions drive.Permissions, } s.mu.RLock() + var matchedShare *drive.Share + for _, sh := range s.shares { + if sh.Name == shareName { + matchedShare = sh + break + } + } childrenMap := s.children s.mu.RUnlock() + if s.maybeServeMagic(authz, matchedShare, pathComponents, w, r) { + return + } + children := make([]*compositedav.Child, 0, len(childrenMap)) - // filter out shares to which the connecting principal has no access + // filter out shares to which the connecting principal has no access. The + // magic share, if present, is also excluded here so a peer with the + // share-level grant can't bypass per-dir filtering by hitting top-level + // dirs directly through compositedav: it's served only via serveMagic. for name, child := range childrenMap { if permissions.For(name) == drive.PermissionNone { continue } - + if buildfeatures.HasDriveMagic && name == drive.MagicShareName { + continue + } children = append(children, child) } diff --git a/drive/driveimpl/remote_impl_magic.go b/drive/driveimpl/remote_impl_magic.go new file mode 100644 index 000000000..bb557d887 --- /dev/null +++ b/drive/driveimpl/remote_impl_magic.go @@ -0,0 +1,147 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !ts_omit_drive_magic + +package driveimpl + +import ( + "net/http" + "os" + "path/filepath" + "sort" + + "github.com/tailscale/xnet/webdav" + "tailscale.com/drive" + "tailscale.com/drive/driveimpl/compositedav" + "tailscale.com/drive/driveimpl/dirfs" + "tailscale.com/drive/magic" +) + +// topLevelDirWriteMethods are write-ish WebDAV methods that, when targeted +// at a magic share's top-level ACL directory itself (e.g. //), +// must be denied. Top-level dir creation/deletion is equivalent to writing +// a grant and is sharer-local only. +var topLevelDirWriteMethods = map[string]bool{ + "MKCOL": true, + "DELETE": true, + "MOVE": true, + "PROPPATCH": true, + "COPY": true, +} + +// maybeServeMagic dispatches to the magic-share handler if share is a magic +// share, returning true if the request was handled. +func (s *FileSystemForRemote) maybeServeMagic(authz drive.Authz, share *drive.Share, pathComponents []string, w http.ResponseWriter, r *http.Request) bool { + if !share.IsMagic() { + return false + } + s.serveMagic(authz, share, pathComponents, w, r) + return true +} + +// serveMagic handles a request whose first path segment is a magic share. +// It enforces ACL-name filtering: peers only see (and can only descend into) +// top-level directories whose name encodes an ACL they match. Top-level dir +// creation/deletion is denied for remote peers. +func (s *FileSystemForRemote) serveMagic(authz drive.Authz, share *drive.Share, pathComponents []string, w http.ResponseWriter, r *http.Request) { + if len(pathComponents) == 1 || (len(pathComponents) == 2 && pathComponents[1] == "") { + if writeMethods[r.Method] { + http.Error(w, "magic share top-level is read-only for remote peers", http.StatusForbidden) + return + } + s.serveMagicTopLevel(authz, share, w, r) + return + } + + aclDir := pathComponents[1] + if !aclDirGrantsPeer(authz, share.Path, aclDir) { + // Use 404 rather than 403 to avoid confirming the dir's existence. + http.Error(w, "not found", http.StatusNotFound) + return + } + + if len(pathComponents) == 2 && topLevelDirWriteMethods[r.Method] { + http.Error(w, "magic top-level dir is sharer-local only", http.StatusForbidden) + return + } + + // Forward to the regular compositedav pipeline. We rebuild children + // rather than reusing the cached map directly, because the magic share + // must remain accessible here even though it was filtered out above. + s.mu.RLock() + childrenMap := s.children + s.mu.RUnlock() + + children := make([]*compositedav.Child, 0, len(childrenMap)) + for name, child := range childrenMap { + if name != share.Name && authz.Permissions.For(name) == drive.PermissionNone { + continue + } + children = append(children, child) + } + + h := compositedav.Handler{Logf: s.logf} + h.SetChildren("", children...) + h.ServeHTTP(w, r) +} + +// serveMagicTopLevel serves a synthetic listing of // that contains +// only the top-level dirs the peer matches. +func (s *FileSystemForRemote) serveMagicTopLevel(authz drive.Authz, share *drive.Share, w http.ResponseWriter, r *http.Request) { + var children []*dirfs.Child + if authz.SharerLogin != "" { + entries, err := os.ReadDir(share.Path) + if err != nil { + s.logf("taildrive magic: read dir %q: %v", share.Path, err) + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + if !e.IsDir() { + continue + } + acl, err := magic.ParseDirACL(e.Name()) + if err != nil { + continue + } + if acl.Matches(authz.PeerLogin, authz.SharerLogin) { + names = append(names, e.Name()) + } + } + sort.Strings(names) + children = make([]*dirfs.Child, 0, len(names)) + for _, n := range names { + children = append(children, &dirfs.Child{Name: n}) + } + } + wh := &webdav.Handler{ + LockSystem: webdav.NewMemLS(), + FileSystem: &dirfs.FS{ + Children: children, + StaticRoot: share.Name, + }, + } + wh.ServeHTTP(w, r) +} + +// aclDirGrantsPeer reports whether aclDir exists on disk under sharePath as a +// directory and its parsed ACL grants the peer access (sharer-in-name rule +// plus peer match). It does no enumeration; only the single aclDir is +// inspected. +func aclDirGrantsPeer(authz drive.Authz, sharePath, aclDir string) bool { + if authz.SharerLogin == "" { + return false + } + acl, err := magic.ParseDirACL(aclDir) + if err != nil { + return false + } + if !acl.Matches(authz.PeerLogin, authz.SharerLogin) { + return false + } + fi, err := os.Stat(filepath.Join(sharePath, aclDir)) + if err != nil || !fi.IsDir() { + return false + } + return true +} diff --git a/drive/driveimpl/remote_impl_nomagic.go b/drive/driveimpl/remote_impl_nomagic.go new file mode 100644 index 000000000..632c2d39f --- /dev/null +++ b/drive/driveimpl/remote_impl_nomagic.go @@ -0,0 +1,19 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build ts_omit_drive_magic + +package driveimpl + +import ( + "net/http" + + "tailscale.com/drive" +) + +// maybeServeMagic is a stub for builds without the magic-share feature. +// It never claims the request, so a share named "magic" is served as a +// regular share with no name-encoded ACL semantics. +func (s *FileSystemForRemote) maybeServeMagic(authz drive.Authz, share *drive.Share, pathComponents []string, w http.ResponseWriter, r *http.Request) bool { + return false +} diff --git a/drive/magic/magic.go b/drive/magic/magic.go new file mode 100644 index 000000000..cac134fae --- /dev/null +++ b/drive/magic/magic.go @@ -0,0 +1,144 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +// Package magic implements the parsing and matching logic for the +// taildrive "magic" share, where the directory name itself encodes the ACL. +// +// A magic ACL directory name is a "+"-delimited list of principals. Each +// principal is either a short login (matching the local-part of a tailnet +// LoginName) or a full login email. The sharer's own login must appear in +// every directory name; a directory whose name omits the sharer is +// considered invalid and shared with no one. +package magic + +import ( + "errors" + "fmt" + "strings" +) + +// ErrInvalidName is returned by ParseDirACL when the directory name is empty +// or contains a malformed principal. +var ErrInvalidName = errors.New("invalid magic acl directory name") + +// DirACL is a parsed magic-share directory name. +type DirACL struct { + // Users is the deduplicated list of user specs in the directory name, + // in the order they first appeared, normalized to lowercase. + // Each spec is either a short login (no "@") or a full login email. + Users []string +} + +// ParseDirACL parses name as a magic ACL. Names are normalized to lowercase +// and split on "+". Returns ErrInvalidName if name is empty or contains a +// malformed principal. +func ParseDirACL(name string) (DirACL, error) { + name = strings.ToLower(strings.TrimSpace(name)) + if name == "" { + return DirACL{}, ErrInvalidName + } + parts := strings.Split(name, "+") + users := make([]string, 0, len(parts)) + seen := make(map[string]bool, len(parts)) + for _, p := range parts { + if !validUserSpec(p) { + return DirACL{}, fmt.Errorf("%w: %q", ErrInvalidName, p) + } + if seen[p] { + continue + } + seen[p] = true + users = append(users, p) + } + return DirACL{Users: users}, nil +} + +// validUserSpec reports whether s is a syntactically valid user spec. +// A short login is a non-empty sequence of [a-z0-9_.-]. An email is a short +// login, "@", and a non-empty sequence of [a-z0-9.-] (a hostname-ish +// fragment). +func validUserSpec(s string) bool { + if s == "" { + return false + } + local, host, hasAt := strings.Cut(s, "@") + if local == "" { + return false + } + if !validLocalPart(local) { + return false + } + if !hasAt { + return true + } + return validHost(host) +} + +func validLocalPart(s string) bool { + for _, r := range s { + switch { + case 'a' <= r && r <= 'z': + case '0' <= r && r <= '9': + case r == '_' || r == '.' || r == '-': + default: + return false + } + } + return true +} + +func validHost(s string) bool { + if s == "" { + return false + } + for _, r := range s { + switch { + case 'a' <= r && r <= 'z': + case '0' <= r && r <= '9': + case r == '.' || r == '-': + default: + return false + } + } + return true +} + +// Matches reports whether peerLogin is granted access by this ACL given that +// the local node's tailnet login is sharerLogin. Both arguments are full +// LoginNames (e.g. "alice@example.com") and are matched case-insensitively. +// +// The "sharer-in-name" rule is enforced: if no principal in the ACL matches +// sharerLogin, the directory is invalid and Matches returns false for every +// peer. +func (a DirACL) Matches(peerLogin, sharerLogin string) bool { + peerLogin = strings.ToLower(peerLogin) + sharerLogin = strings.ToLower(sharerLogin) + sharerOK := false + peerOK := false + for _, u := range a.Users { + if matchesUser(u, sharerLogin) { + sharerOK = true + } + if matchesUser(u, peerLogin) { + peerOK = true + } + } + return sharerOK && peerOK +} + +// matchesUser reports whether spec matches login. spec and login are +// pre-lowercased. spec is either a short login (matched against the +// local-part of login) or a full email (matched against login as a whole). +func matchesUser(spec, login string) bool { + if login == "" { + return false + } + if strings.ContainsRune(spec, '@') { + return spec == login + } + local, _, ok := strings.Cut(login, "@") + if !ok { + local = login + } + return spec == local +} diff --git a/drive/magic/magic_test.go b/drive/magic/magic_test.go new file mode 100644 index 000000000..9bc433085 --- /dev/null +++ b/drive/magic/magic_test.go @@ -0,0 +1,82 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package magic + +import ( + "slices" + "testing" +) + +func TestParseDirACL(t *testing.T) { + tests := []struct { + name string + want []string + wantErr bool + }{ + {"fserb", []string{"fserb"}, false}, + {"fserb+rhea", []string{"fserb", "rhea"}, false}, + {"Fserb+RHEA", []string{"fserb", "rhea"}, false}, + {"fserb+fserb", []string{"fserb"}, false}, + {"fserb+rhea+joe", []string{"fserb", "rhea", "joe"}, false}, + {"fserb@example.com+rhea", []string{"fserb@example.com", "rhea"}, false}, + + {"", nil, true}, + {"+fserb", nil, true}, + {"fserb+", nil, true}, + {"fserb++rhea", nil, true}, + {"fserb rhea", nil, true}, + {"fserb!", nil, true}, + {"@example.com", nil, true}, + {"fserb@", nil, true}, + {"fserb@@example.com", nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseDirACL(tt.name) + if (err != nil) != tt.wantErr { + t.Fatalf("err=%v, wantErr=%v", err, tt.wantErr) + } + if tt.wantErr { + return + } + if !slices.Equal(got.Users, tt.want) { + t.Errorf("Users=%v, want %v", got.Users, tt.want) + } + }) + } +} + +func TestMatches(t *testing.T) { + const sharer = "fserb@example.com" + tests := []struct { + name string + dir string + peerLogin string + want bool + }{ + {"sharer matches own dir", "fserb", sharer, true}, + {"sharer matches paired dir", "fserb+rhea", sharer, true}, + {"peer matches paired dir (short)", "fserb+rhea", "rhea@example.com", true}, + {"peer matches paired dir (email)", "fserb+rhea@example.com", "rhea@example.com", true}, + {"peer mismatch full email", "fserb+rhea@other.com", "rhea@example.com", false}, + {"peer not in dir", "fserb+rhea", "joe@example.com", false}, + {"sharer-not-in-name invalidates dir for peer", "rhea+joe", "rhea@example.com", false}, + {"sharer-not-in-name invalidates dir for sharer too", "rhea+joe", sharer, false}, + {"empty peer login", "fserb", "", false}, + {"three principal dir, peer ok", "fserb+rhea+joe", "joe@example.com", true}, + {"case-insensitive peer", "fserb+rhea", "RHEA@example.com", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + acl, err := ParseDirACL(tt.dir) + if err != nil { + t.Fatalf("parse %q: %v", tt.dir, err) + } + got := acl.Matches(tt.peerLogin, sharer) + if got != tt.want { + t.Errorf("Matches(%q, %q)=%v, want %v", tt.peerLogin, sharer, got, tt.want) + } + }) + } +} diff --git a/drive/remote.go b/drive/remote.go index 5f34d0023..b1288abb2 100644 --- a/drive/remote.go +++ b/drive/remote.go @@ -48,6 +48,17 @@ type Share struct { BookmarkData []byte `json:"bookmarkData,omitempty"` } +// MagicShareName is the reserved share name for a magic share, where each +// top-level directory name inside the share encodes its own ACL (see package +// drive/magic). A share is treated as magic if and only if its Name equals +// MagicShareName. +const MagicShareName = "magic" + +// IsMagic reports whether s is a magic share. +func (s *Share) IsMagic() bool { + return s != nil && s.Name == MagicShareName +} + func ShareViewsEqual(a, b ShareView) bool { if !a.Valid() && !b.Valid() { return true @@ -102,9 +113,8 @@ type FileSystemForRemote interface { SetShares(shares []*Share) // ServeHTTPWithPerms behaves like the similar method from http.Handler but - // also accepts a Permissions map that captures the permissions of the - // connecting node. - ServeHTTPWithPerms(permissions Permissions, w http.ResponseWriter, r *http.Request) + // also accepts an Authz value capturing the peer's identity and permissions. + ServeHTTPWithPerms(authz Authz, w http.ResponseWriter, r *http.Request) // Close() stops serving the WebDAV content Close() error diff --git a/drive/remote_permissions.go b/drive/remote_permissions.go index 31ec0caee..74497258d 100644 --- a/drive/remote_permissions.go +++ b/drive/remote_permissions.go @@ -27,6 +27,23 @@ const ( // set of shares. type Permissions map[string]Permission +// Authz captures the authorization context for a request to +// FileSystemForRemote: the peer's tailnet identity and per-share permissions, +// plus the local node's tailnet identity (used for the magic share's +// "sharer-in-name" rule). +type Authz struct { + // PeerLogin is the requesting peer's tailnet LoginName (e.g. + // "alice@example.com"). Lowercased for case-insensitive matching. + PeerLogin string + + // SharerLogin is the local (sharing) node's tailnet LoginName. + // Lowercased for case-insensitive matching. + SharerLogin string + + // Permissions maps share name to the peer's permission on that share. + Permissions Permissions +} + type grant struct { Shares []string Access string diff --git a/feature/buildfeatures/feature_drive_magic_disabled.go b/feature/buildfeatures/feature_drive_magic_disabled.go new file mode 100644 index 000000000..cb32076ee --- /dev/null +++ b/feature/buildfeatures/feature_drive_magic_disabled.go @@ -0,0 +1,13 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +// Code generated by gen.go; DO NOT EDIT. + +//go:build ts_omit_drive_magic + +package buildfeatures + +// HasDriveMagic is whether the binary was built with support for modular feature "Taildrive \"magic\" share (directory-name-encoded ACLs)". +// Specifically, it's whether the binary was NOT built with the "ts_omit_drive_magic" build tag. +// It's a const so it can be used for dead code elimination. +const HasDriveMagic = false diff --git a/feature/buildfeatures/feature_drive_magic_enabled.go b/feature/buildfeatures/feature_drive_magic_enabled.go new file mode 100644 index 000000000..57cc4c8d7 --- /dev/null +++ b/feature/buildfeatures/feature_drive_magic_enabled.go @@ -0,0 +1,13 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +// Code generated by gen.go; DO NOT EDIT. + +//go:build !ts_omit_drive_magic + +package buildfeatures + +// HasDriveMagic is whether the binary was built with support for modular feature "Taildrive \"magic\" share (directory-name-encoded ACLs)". +// Specifically, it's whether the binary was NOT built with the "ts_omit_drive_magic" build tag. +// It's a const so it can be used for dead code elimination. +const HasDriveMagic = true diff --git a/feature/featuretags/featuretags.go b/feature/featuretags/featuretags.go index 4f34acbe8..d62293841 100644 --- a/feature/featuretags/featuretags.go +++ b/feature/featuretags/featuretags.go @@ -160,6 +160,11 @@ var Features = map[FeatureTag]FeatureMeta{ "desktop_sessions": {Sym: "DesktopSessions", Desc: "Desktop sessions support"}, "doctor": {Sym: "Doctor", Desc: "Diagnose possible issues with Tailscale and its host environment"}, "drive": {Sym: "Drive", Desc: "Tailscale Drive (file server) support"}, + "drive_magic": { + Sym: "DriveMagic", + Desc: "Taildrive \"magic\" share (directory-name-encoded ACLs)", + Deps: []FeatureTag{"drive"}, + }, "gro": { Sym: "GRO", Desc: "Generic Receive Offload support (performance)", diff --git a/feature/featuretags/featuretags_test.go b/feature/featuretags/featuretags_test.go index c8a9f77ae..2ebd07666 100644 --- a/feature/featuretags/featuretags_test.go +++ b/feature/featuretags/featuretags_test.go @@ -68,7 +68,7 @@ func TestRequiredBy(t *testing.T) { }{ { in: "drive", - want: setOf("drive"), + want: setOf("drive", "drive_magic"), }, { in: "webclient", diff --git a/ipn/ipnlocal/peerapi_drive.go b/ipn/ipnlocal/peerapi_drive.go index d42843577..4aa56def0 100644 --- a/ipn/ipnlocal/peerapi_drive.go +++ b/ipn/ipnlocal/peerapi_drive.go @@ -11,6 +11,7 @@ import ( "strings" "tailscale.com/drive" + "tailscale.com/feature/buildfeatures" "tailscale.com/tailcfg" "tailscale.com/util/httpm" ) @@ -53,6 +54,20 @@ func handleServeDrive(hi PeerAPIHandler, w http.ResponseWriter, r *http.Request) return } + var sharerLogin string + if buildfeatures.HasDriveMagic { + if nm := h.ps.b.NetMap(); nm != nil { + if up, ok := nm.UserProfiles[nm.User()]; ok { + sharerLogin = up.LoginName() + } + } + } + authz := drive.Authz{ + PeerLogin: strings.ToLower(h.peerUser.LoginName), + SharerLogin: strings.ToLower(sharerLogin), + Permissions: p, + } + fs, ok := h.ps.b.sys.DriveForRemote.GetOK() if !ok { h.logf("taildrive: not supported on platform") @@ -86,7 +101,7 @@ func handleServeDrive(hi PeerAPIHandler, w http.ResponseWriter, r *http.Request) }() r.URL.Path = strings.TrimPrefix(r.URL.Path, taildrivePrefix) - fs.ServeHTTPWithPerms(p, wr, r) + fs.ServeHTTPWithPerms(authz, wr, r) } // parseDriveFileExtensionForLog parses the file extension, if available.