diff --git a/ipn/ipnserver/server.go b/ipn/ipnserver/server.go index 1ff4cc1ab..5a6507090 100644 --- a/ipn/ipnserver/server.go +++ b/ipn/ipnserver/server.go @@ -11,7 +11,9 @@ import ( "io" "net" "net/http" + "os/exec" "os/user" + "runtime" "strconv" "strings" "sync" @@ -30,6 +32,7 @@ import ( "tailscale.com/util/mak" "tailscale.com/util/set" "tailscale.com/util/systemd" + "tailscale.com/version" ) // Server is an IPN backend and its set of 0 or more active localhost @@ -371,16 +374,52 @@ func (s *Server) connCanFetchCerts(ci *ipnauth.ConnIdentity) bool { // This is useful because, on Windows, tailscaled itself always runs with // elevated rights: we want to avoid privilege escalation for certain mutative operations. func (s *Server) connIsLocalAdmin(ci *ipnauth.ConnIdentity) bool { - tok, err := ci.WindowsToken() - if err != nil { - if !errors.Is(err, ipnauth.ErrNotImplemented) { - s.logf("ipnauth.ConnIdentity.WindowsToken() error: %v", err) + switch runtime.GOOS { + case "windows": + tok, err := ci.WindowsToken() + if err != nil { + if !errors.Is(err, ipnauth.ErrNotImplemented) { + s.logf("ipnauth.ConnIdentity.WindowsToken() error: %v", err) + } + return false + } + defer tok.Close() + + return tok.IsElevated() + + case "darwin": + if version.IsSandboxedMacOS() { + return false + } + // This is a standalone tailscaled setup, use the same logic as on + // Linux. + fallthrough + case "linux": + uid, ok := ci.Creds().UserID() + if !ok { + return false } + // root is always admin. + if uid == "0" { + return true + } + // if non-root, must be operator AND able to execute "sudo tailscale". + operatorUID := s.mustBackend().OperatorUserID() + if operatorUID != "" && uid != operatorUID { + return false + } + u, err := user.LookupId(uid) + if err != nil { + return false + } + if err := exec.Command("sudo", "--other-user="+u.Name, "--list", "tailscale").Run(); err != nil { + return false + } + return true + + default: return false } - defer tok.Close() - - return tok.IsElevated() } // addActiveHTTPRequest adds c to the server's list of active HTTP requests. diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index a8b8a995f..4137d89e2 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -920,8 +920,8 @@ func (h *Handler) serveServeConfig(w http.ResponseWriter, r *http.Request) { // require a local admin when setting a path handler // TODO: roll-up this Windows-specific check into either PermitWrite // or a global admin escalation check. - if shouldDenyServeConfigForGOOSAndUserContext(runtime.GOOS, configIn, h) { - http.Error(w, "must be a Windows local admin to serve a path", http.StatusUnauthorized) + if err := authorizeServeConfigForGOOSAndUserContext(runtime.GOOS, configIn, h); err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) return } @@ -940,14 +940,30 @@ func (h *Handler) serveServeConfig(w http.ResponseWriter, r *http.Request) { } } -func shouldDenyServeConfigForGOOSAndUserContext(goos string, configIn *ipn.ServeConfig, h *Handler) bool { - if goos != "windows" { - return false +func authorizeServeConfigForGOOSAndUserContext(goos string, configIn *ipn.ServeConfig, h *Handler) error { + if !slices.Contains([]string{"windows", "linux", "darwin"}, goos) { + return nil + } + if goos == "darwin" && !version.IsSandboxedMacOS() { + return nil } if !configIn.HasPathHandler() { - return false + return nil + } + if h.CallerIsLocalAdmin { + return nil } - return !h.CallerIsLocalAdmin + switch goos { + case "windows": + return errors.New("must be a Windows local admin to serve a path") + case "linux", "darwin": + return errors.New("must be root, or be an operator and able to run 'sudo tailscale' to serve a path") + default: + // We filter goos at the start of the func, this default case + // should never happen. + panic("unreachable") + } + } func (h *Handler) serveCheckIPForwarding(w http.ResponseWriter, r *http.Request) { diff --git a/ipn/localapi/localapi_test.go b/ipn/localapi/localapi_test.go index 3a348b8ee..2988e9604 100644 --- a/ipn/localapi/localapi_test.go +++ b/ipn/localapi/localapi_test.go @@ -161,14 +161,40 @@ func TestShouldDenyServeConfigForGOOSAndUserContext(t *testing.T) { goos string configIn *ipn.ServeConfig h *Handler - want bool + wantErr bool }{ { name: "linux", goos: "linux", configIn: &ipn.ServeConfig{}, h: &Handler{CallerIsLocalAdmin: false}, - want: false, + wantErr: false, + }, + { + name: "linux-path-handler-admin", + goos: "linux", + configIn: &ipn.ServeConfig{ + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ + "/": {Path: "/tmp"}, + }}, + }, + }, + h: &Handler{CallerIsLocalAdmin: true}, + wantErr: false, + }, + { + name: "linux-path-handler-not-admin", + goos: "linux", + configIn: &ipn.ServeConfig{ + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ + "/": {Path: "/tmp"}, + }}, + }, + }, + h: &Handler{CallerIsLocalAdmin: false}, + wantErr: true, }, { name: "windows-not-path-handler", @@ -180,8 +206,8 @@ func TestShouldDenyServeConfigForGOOSAndUserContext(t *testing.T) { }}, }, }, - h: &Handler{CallerIsLocalAdmin: false}, - want: false, + h: &Handler{CallerIsLocalAdmin: false}, + wantErr: false, }, { name: "windows-path-handler-admin", @@ -193,8 +219,8 @@ func TestShouldDenyServeConfigForGOOSAndUserContext(t *testing.T) { }}, }, }, - h: &Handler{CallerIsLocalAdmin: true}, - want: false, + h: &Handler{CallerIsLocalAdmin: true}, + wantErr: false, }, { name: "windows-path-handler-not-admin", @@ -206,16 +232,17 @@ func TestShouldDenyServeConfigForGOOSAndUserContext(t *testing.T) { }}, }, }, - h: &Handler{CallerIsLocalAdmin: false}, - want: true, + h: &Handler{CallerIsLocalAdmin: false}, + wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := shouldDenyServeConfigForGOOSAndUserContext(tt.goos, tt.configIn, tt.h) - if got != tt.want { - t.Errorf("shouldDenyServeConfigForGOOSAndUserContext() got = %v, want %v", got, tt.want) + err := authorizeServeConfigForGOOSAndUserContext(tt.goos, tt.configIn, tt.h) + gotErr := err != nil + if gotErr != tt.wantErr { + t.Errorf("authorizeServeConfigForGOOSAndUserContext() got error = %v, want error %v", err, tt.wantErr) } }) }