drive: use secret token to authenticate access to file server on localhost

This prevents Mark-of-the-Web bypass attacks in case someone visits the
localhost WebDAV server directly.

Fixes tailscale/corp#19592

Signed-off-by: Percy Wegmann <percy@tailscale.com>
This commit is contained in:
Percy Wegmann 2024-05-01 10:45:57 -05:00 committed by Percy Wegmann
parent cd633a7252
commit 9d22ec0ba2
6 changed files with 135 additions and 53 deletions

View File

@ -27,10 +27,10 @@
type Child struct { type Child struct {
*dirfs.Child *dirfs.Child
// BaseURL is the base URL of the WebDAV service to which we'll proxy // BaseURL returns the base URL of the WebDAV service to which we'll proxy
// requests for this Child. We will append the filename from the original // requests for this Child. We will append the filename from the original
// URL to this. // URL to this.
BaseURL string BaseURL func() (string, error)
// Transport (if specified) is the http transport to use when communicating // Transport (if specified) is the http transport to use when communicating
// with this Child's WebDAV service. // with this Child's WebDAV service.
@ -154,7 +154,13 @@ func (h *Handler) delegate(mpl int, pathComponents []string, w http.ResponseWrit
return return
} }
u, err := url.Parse(child.BaseURL) baseURL, err := child.BaseURL()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
u, err := url.Parse(baseURL)
if err != nil { if err != nil {
h.logf("warning: parse base URL %s failed: %s", child.BaseURL, err) h.logf("warning: parse base URL %s failed: %s", child.BaseURL, err)
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@ -10,10 +10,12 @@
"log" "log"
"net" "net"
"net/http" "net/http"
"net/url"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"slices" "slices"
"strings"
"sync" "sync"
"testing" "testing"
"time" "time"
@ -112,7 +114,31 @@ func TestPermissions(t *testing.T) {
if err := s.client.Rename(pathTo(remote1, share12, file111), pathTo(remote1, share12, file112), true); err == nil { if err := s.client.Rename(pathTo(remote1, share12, file111), pathTo(remote1, share12, file112), true); err == nil {
t.Error("moving file on read-only remote should fail") t.Error("moving file on read-only remote should fail")
} }
}
// TestSecretTokenAuth verifies that the fileserver running at localhost cannot
// be accessed directly without the correct secret token. This matters because
// if a victim can be induced to visit the localhost URL and access a malicious
// file on their own share, it could allow a Mark-of-the-Web bypass attack.
func TestSecretTokenAuth(t *testing.T) {
s := newSystem(t)
fileserverAddr := s.addRemote(remote1)
s.addShare(remote1, share11, drive.PermissionReadWrite)
s.writeFile("writing file to read/write remote should succeed", remote1, share11, file111, "hello world", true)
client := &http.Client{
Transport: &http.Transport{DisableKeepAlives: true},
}
addr := strings.Split(fileserverAddr, "|")[1]
u := fmt.Sprintf("http://%s/%s/%s", addr, "fakesecret", url.PathEscape(file111))
resp, err := client.Get(u)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusForbidden {
t.Errorf("expected %d for incorrect secret token, but got %d", http.StatusForbidden, resp.StatusCode)
}
} }
type local struct { type local struct {
@ -183,7 +209,7 @@ func newSystem(t *testing.T) *system {
return s return s
} }
func (s *system) addRemote(name string) { func (s *system) addRemote(name string) string {
l, err := net.Listen("tcp", "127.0.0.1:0") l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil { if err != nil {
s.t.Fatalf("failed to Listen: %s", err) s.t.Fatalf("failed to Listen: %s", err)
@ -222,6 +248,8 @@ func (s *system) addRemote(name string) {
DisableKeepAlives: true, DisableKeepAlives: true,
ResponseHeaderTimeout: 5 * time.Second, ResponseHeaderTimeout: 5 * time.Second,
}) })
return fileServer.Addr()
} }
func (s *system) addShare(remoteName, shareName string, permission drive.Permission) { func (s *system) addShare(remoteName, shareName string, permission drive.Permission) {

View File

@ -4,6 +4,9 @@
package driveimpl package driveimpl
import ( import (
"crypto/rand"
"encoding/hex"
"fmt"
"net" "net"
"net/http" "net/http"
"sync" "sync"
@ -17,6 +20,7 @@
// serve up files as an unprivileged user. // serve up files as an unprivileged user.
type FileServer struct { type FileServer struct {
l net.Listener l net.Listener
secretToken string
shareHandlers map[string]http.Handler shareHandlers map[string]http.Handler
sharesMu sync.RWMutex sharesMu sync.RWMutex
} }
@ -41,18 +45,27 @@ func NewFileServer() (*FileServer, error) {
// TODO(oxtoacart): actually get safesocket working in more environments (MacOS Sandboxed, Windows, ???) // TODO(oxtoacart): actually get safesocket working in more environments (MacOS Sandboxed, Windows, ???)
l, err := net.Listen("tcp", "127.0.0.1:0") l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("listen: %w", err)
} }
// } // }
tokenBytes := make([]byte, 32)
_, err = rand.Read(tokenBytes)
if err != nil {
return nil, fmt.Errorf("generate token bytes: %w", err)
}
return &FileServer{ return &FileServer{
l: l, l: l,
secretToken: hex.EncodeToString(tokenBytes),
shareHandlers: make(map[string]http.Handler), shareHandlers: make(map[string]http.Handler),
}, nil }, nil
} }
// Addr returns the address at which this FileServer is listening. // Addr returns the address at which this FileServer is listening. This
// includes the secret token in front of the address, delimited by a pipe |.
func (s *FileServer) Addr() string { func (s *FileServer) Addr() string {
return s.l.Addr().String() return fmt.Sprintf("%s|%s", s.secretToken, s.l.Addr().String())
} }
// Serve() starts serving files and blocks until it encounters a fatal error. // Serve() starts serving files and blocks until it encounters a fatal error.
@ -95,11 +108,22 @@ func (s *FileServer) SetShares(shares map[string]string) {
} }
} }
// ServeHTTP implements the http.Handler interface. // ServeHTTP implements the http.Handler interface. In order to prevent
// Mark-of-the-Web bypass attacks if someone visits this fileserver directly
// within a browser, it requires that the first path element be this file
// server's secret token. Without knowing the secret token, it's impossible
// to construct a URL that passes validation.
func (s *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (s *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
parts := shared.CleanAndSplit(r.URL.Path) parts := shared.CleanAndSplit(r.URL.Path)
r.URL.Path = shared.Join(parts[1:]...)
share := parts[0] token := parts[0]
if token != s.secretToken {
w.WriteHeader(http.StatusForbidden)
return
}
r.URL.Path = shared.Join(parts[2:]...)
share := parts[1]
s.sharesMu.RLock() s.sharesMu.RLock()
h, found := s.shareHandlers[share] h, found := s.shareHandlers[share]
s.sharesMu.RUnlock() s.sharesMu.RUnlock()

View File

@ -77,7 +77,7 @@ func (s *FileSystemForLocal) SetRemotes(domain string, remotes []*drive.Remote,
Name: remote.Name, Name: remote.Name,
Available: remote.Available, Available: remote.Available,
}, },
BaseURL: remote.URL, BaseURL: func() (string, error) { return remote.URL, nil },
Transport: transport, Transport: transport,
}) })
} }

View File

@ -51,17 +51,18 @@ type FileSystemForRemote struct {
// mu guards the below values. Acquire a write lock before updating any of // mu guards the below values. Acquire a write lock before updating any of
// them, acquire a read lock before reading any of them. // them, acquire a read lock before reading any of them.
mu sync.RWMutex mu sync.RWMutex
fileServerAddr string // fileServerTokenAndAddr is the secretToken|fileserverAddress
shares []*drive.Share fileServerTokenAndAddr string
children map[string]*compositedav.Child shares []*drive.Share
userServers map[string]*userServer children map[string]*compositedav.Child
userServers map[string]*userServer
} }
// SetFileServerAddr implements drive.FileSystemForRemote. // SetFileServerAddr implements drive.FileSystemForRemote.
func (s *FileSystemForRemote) SetFileServerAddr(addr string) { func (s *FileSystemForRemote) SetFileServerAddr(addr string) {
s.mu.Lock() s.mu.Lock()
s.fileServerAddr = addr s.fileServerTokenAndAddr = addr
s.mu.Unlock() s.mu.Unlock()
} }
@ -113,11 +114,58 @@ func (s *FileSystemForRemote) SetShares(shares []*drive.Share) {
} }
func (s *FileSystemForRemote) buildChild(share *drive.Share) *compositedav.Child { func (s *FileSystemForRemote) buildChild(share *drive.Share) *compositedav.Child {
getTokenAndAddr := func(shareName string) (string, string, error) {
s.mu.RLock()
var share *drive.Share
i, shareFound := slices.BinarySearchFunc(s.shares, shareName, func(s *drive.Share, name string) int {
return strings.Compare(s.Name, name)
})
if shareFound {
share = s.shares[i]
}
userServers := s.userServers
fileServerTokenAndAddr := s.fileServerTokenAndAddr
s.mu.RUnlock()
if !shareFound {
return "", "", fmt.Errorf("unknown share %v", shareName)
}
var tokenAndAddr string
if !drive.AllowShareAs() {
tokenAndAddr = fileServerTokenAndAddr
} else {
userServer, found := userServers[share.As]
if found {
userServer.mu.RLock()
tokenAndAddr = userServer.tokenAndAddr
userServer.mu.RUnlock()
}
}
if tokenAndAddr == "" {
return "", "", fmt.Errorf("unable to determine address for share %v", shareName)
}
parts := strings.Split(tokenAndAddr, "|")
if len(parts) != 2 {
return "", "", fmt.Errorf("invalid address for share %v", shareName)
}
return parts[0], parts[1], nil
}
return &compositedav.Child{ return &compositedav.Child{
Child: &dirfs.Child{ Child: &dirfs.Child{
Name: share.Name, Name: share.Name,
}, },
BaseURL: fmt.Sprintf("http://%v/%v", hex.EncodeToString([]byte(share.Name)), url.PathEscape(share.Name)), BaseURL: func() (string, error) {
secretToken, _, err := getTokenAndAddr(share.Name)
if err != nil {
return "", err
}
return fmt.Sprintf("http://%s/%s/%s", hex.EncodeToString([]byte(share.Name)), secretToken, url.PathEscape(share.Name)), nil
},
Transport: &http.Transport{ Transport: &http.Transport{
Dial: func(_, shareAddr string) (net.Conn, error) { Dial: func(_, shareAddr string) (net.Conn, error) {
shareNameHex, _, err := net.SplitHostPort(shareAddr) shareNameHex, _, err := net.SplitHostPort(shareAddr)
@ -132,36 +180,9 @@ func (s *FileSystemForRemote) buildChild(share *drive.Share) *compositedav.Child
} }
shareName := string(shareNameBytes) shareName := string(shareNameBytes)
s.mu.RLock() _, addr, err := getTokenAndAddr(shareName)
var share *drive.Share if err != nil {
i, shareFound := slices.BinarySearchFunc(s.shares, shareName, func(s *drive.Share, name string) int { return nil, err
return strings.Compare(s.Name, name)
})
if shareFound {
share = s.shares[i]
}
userServers := s.userServers
fileServerAddr := s.fileServerAddr
s.mu.RUnlock()
if !shareFound {
return nil, fmt.Errorf("unknown share %v", shareName)
}
var addr string
if !drive.AllowShareAs() {
addr = fileServerAddr
} else {
userServer, found := userServers[share.As]
if found {
userServer.mu.RLock()
addr = userServer.addr
userServer.mu.RUnlock()
}
}
if addr == "" {
return nil, fmt.Errorf("unable to determine address for share %v", shareName)
} }
_, err = netip.ParseAddrPort(addr) _, err = netip.ParseAddrPort(addr)
@ -253,10 +274,10 @@ type userServer struct {
// mu guards the below values. Acquire a write lock before updating any of // mu guards the below values. Acquire a write lock before updating any of
// them, acquire a read lock before reading any of them. // them, acquire a read lock before reading any of them.
mu sync.RWMutex mu sync.RWMutex
cmd *exec.Cmd cmd *exec.Cmd
addr string tokenAndAddr string
closed bool closed bool
} }
func (s *userServer) Close() error { func (s *userServer) Close() error {
@ -366,7 +387,7 @@ func (s *userServer) run() error {
} }
}() }()
s.mu.Lock() s.mu.Lock()
s.addr = strings.TrimSpace(addr) s.tokenAndAddr = strings.TrimSpace(addr)
s.mu.Unlock() s.mu.Unlock()
return cmd.Wait() return cmd.Wait()
} }

View File

@ -95,6 +95,9 @@ type FileSystemForRemote interface {
// sandboxed where we can't spawn user-specific sub-processes and instead // sandboxed where we can't spawn user-specific sub-processes and instead
// rely on the UI application that's already running as an unprivileged // rely on the UI application that's already running as an unprivileged
// user to access the filesystem for us. // user to access the filesystem for us.
//
// Note that this includes both the file server's secret token and its
// address, delimited by a pipe |.
SetFileServerAddr(addr string) SetFileServerAddr(addr string)
// SetShares sets the complete set of shares exposed by this node. If // SetShares sets the complete set of shares exposed by this node. If