Pārlūkot izejas kodu

EventManager: allow to define the allowed system commands

Signed-off-by: Nicola Murino <[email protected]>
Nicola Murino 11 mēneši atpakaļ
vecāks
revīzija
5c163ed592

+ 24 - 1
internal/common/common.go

@@ -228,6 +228,9 @@ func Initialize(c Configuration, isShared int) error {
 	if err := c.initializeProxyProtocol(); err != nil {
 		return err
 	}
+	if err := c.EventManager.validate(); err != nil {
+		return err
+	}
 	vfs.SetTempPath(c.TempPath)
 	dataprovider.SetTempPath(c.TempPath)
 	vfs.SetAllowSelfConnections(c.AllowSelfConnections)
@@ -236,6 +239,7 @@ func Initialize(c Configuration, isShared int) error {
 	vfs.SetResumeMaxSize(c.ResumeMaxSize)
 	vfs.SetUploadMode(c.UploadMode)
 	dataprovider.SetAllowSelfConnections(c.AllowSelfConnections)
+	dataprovider.EnabledActionCommands = c.EventManager.EnabledCommands
 	transfersChecker = getTransfersChecker(isShared)
 	return nil
 }
@@ -484,6 +488,23 @@ type ConnectionTransfer struct {
 	DLSize        int64  `json:"-"`
 }
 
+// EventManagerConfig defines the configuration for the EventManager
+type EventManagerConfig struct {
+	// EnabledCommands defines the system commands that can be executed via EventManager,
+	// an empty list means that any command is allowed to be executed.
+	// Commands must be set as an absolute path
+	EnabledCommands []string `json:"enabled_commands" mapstructure:"enabled_commands"`
+}
+
+func (c *EventManagerConfig) validate() error {
+	for _, c := range c.EnabledCommands {
+		if !filepath.IsAbs(c) {
+			return fmt.Errorf("invalid command %q: it must be an absolute path", c)
+		}
+	}
+	return nil
+}
+
 // MetadataConfig defines how to handle metadata for cloud storage backends
 type MetadataConfig struct {
 	// If not zero the metadata will be read before downloads and will be
@@ -589,7 +610,9 @@ type Configuration struct {
 	// Defines the server version
 	ServerVersion string `json:"server_version" mapstructure:"server_version"`
 	// Metadata configuration
-	Metadata              MetadataConfig `json:"metadata" mapstructure:"metadata"`
+	Metadata MetadataConfig `json:"metadata" mapstructure:"metadata"`
+	// EventManager configuration
+	EventManager          EventManagerConfig `json:"event_manager" mapstructure:"event_manager"`
 	idleTimeoutAsDuration time.Duration
 	idleLoginTimeout      time.Duration
 	defender              Defender

+ 27 - 0
internal/common/common_test.go

@@ -216,6 +216,33 @@ func TestConnections(t *testing.T) {
 	Connections.RUnlock()
 }
 
+func TestEventManagerCommandsInitialization(t *testing.T) {
+	configCopy := Config
+
+	c := Configuration{
+		EventManager: EventManagerConfig{
+			EnabledCommands: []string{"ls"}, // not an absolute path
+		},
+	}
+	err := Initialize(c, 0)
+	assert.ErrorContains(t, err, "invalid command")
+
+	var commands []string
+	if runtime.GOOS == osWindows {
+		commands = []string{"C:\\command"}
+	} else {
+		commands = []string{"/bin/ls"}
+	}
+
+	c.EventManager.EnabledCommands = commands
+	err = Initialize(c, 0)
+	assert.NoError(t, err)
+	assert.Equal(t, commands, dataprovider.EnabledActionCommands)
+
+	dataprovider.EnabledActionCommands = configCopy.EventManager.EnabledCommands
+	Config = configCopy
+}
+
 func TestInitializationProxyErrors(t *testing.T) {
 	configCopy := Config
 

+ 3 - 0
internal/common/eventmanager.go

@@ -1480,6 +1480,9 @@ func executeHTTPRuleAction(c dataprovider.EventActionHTTPConfig, params *EventPa
 }
 
 func executeCommandRuleAction(c dataprovider.EventActionCommandConfig, params *EventParams) error {
+	if !dataprovider.IsActionCommandAllowed(c.Cmd) {
+		return fmt.Errorf("command %q is not allowed", c.Cmd)
+	}
 	addObjectData := false
 	if params.Object != nil {
 		for _, k := range c.EnvVars {

+ 142 - 0
internal/common/protocol_test.go

@@ -4208,6 +4208,148 @@ func TestEventRuleStatues(t *testing.T) {
 	require.NoError(t, err)
 }
 
+func TestEventRuleDisabledCommand(t *testing.T) {
+	if runtime.GOOS == osWindows {
+		t.Skip("this test is not available on Windows")
+	}
+	smtpCfg := smtp.Config{
+		Host:          "127.0.0.1",
+		Port:          2525,
+		From:          "[email protected]",
+		TemplatesPath: "templates",
+	}
+	err := smtpCfg.Initialize(configDir, true)
+	require.NoError(t, err)
+
+	saveObjectScriptPath := filepath.Join(os.TempDir(), "provider.sh")
+	outPath := filepath.Join(os.TempDir(), "provider_out.json")
+	err = os.WriteFile(saveObjectScriptPath, getSaveProviderObjectScriptContent(outPath, 0), 0755)
+	assert.NoError(t, err)
+
+	a1 := dataprovider.BaseEventAction{
+		Name: "a1",
+		Type: dataprovider.ActionTypeCommand,
+		Options: dataprovider.BaseEventActionOptions{
+			CmdConfig: dataprovider.EventActionCommandConfig{
+				Cmd:     saveObjectScriptPath,
+				Timeout: 10,
+				EnvVars: []dataprovider.KeyValue{
+					{
+						Key:   "SFTPGO_OBJECT_DATA",
+						Value: "{{ObjectData}}",
+					},
+				},
+			},
+		},
+	}
+	a2 := dataprovider.BaseEventAction{
+		Name: "a2",
+		Type: dataprovider.ActionTypeEmail,
+		Options: dataprovider.BaseEventActionOptions{
+			EmailConfig: dataprovider.EventActionEmailConfig{
+				Recipients: []string{"[email protected]"},
+				Subject:    `New "{{Event}}" from "{{Name}}"`,
+				Body:       "Object name: {{ObjectName}} object type: {{ObjectType}} Data: {{ObjectData}}",
+			},
+		},
+	}
+
+	a3 := dataprovider.BaseEventAction{
+		Name: "a3",
+		Type: dataprovider.ActionTypeEmail,
+		Options: dataprovider.BaseEventActionOptions{
+			EmailConfig: dataprovider.EventActionEmailConfig{
+				Recipients: []string{"[email protected]"},
+				Subject:    `Failed "{{Event}}" from "{{Name}}"`,
+				Body:       "Object name: {{ObjectName}} object type: {{ObjectType}}, IP: {{IP}}",
+			},
+		},
+	}
+	action1, _, err := httpdtest.AddEventAction(a1, http.StatusCreated)
+	assert.NoError(t, err)
+	action2, _, err := httpdtest.AddEventAction(a2, http.StatusCreated)
+	assert.NoError(t, err)
+	action3, _, err := httpdtest.AddEventAction(a3, http.StatusCreated)
+	assert.NoError(t, err)
+
+	r := dataprovider.EventRule{
+		Name:    "rule",
+		Status:  1,
+		Trigger: dataprovider.EventTriggerProviderEvent,
+		Conditions: dataprovider.EventConditions{
+			ProviderEvents: []string{"add"},
+			Options: dataprovider.ConditionOptions{
+				ProviderObjects: []string{"folder"},
+			},
+		},
+		Actions: []dataprovider.EventAction{
+			{
+				BaseEventAction: dataprovider.BaseEventAction{
+					Name: action1.Name,
+				},
+				Order: 1,
+				Options: dataprovider.EventActionOptions{
+					StopOnFailure: true,
+				},
+			},
+			{
+				BaseEventAction: dataprovider.BaseEventAction{
+					Name: action2.Name,
+				},
+				Order: 2,
+			},
+			{
+				BaseEventAction: dataprovider.BaseEventAction{
+					Name: action3.Name,
+				},
+				Order: 3,
+				Options: dataprovider.EventActionOptions{
+					IsFailureAction: true,
+					StopOnFailure:   true,
+				},
+			},
+		},
+	}
+	rule, _, err := httpdtest.AddEventRule(r, http.StatusCreated)
+	assert.NoError(t, err)
+	// restrit command execution
+	dataprovider.EnabledActionCommands = []string{"/bin/ls"}
+
+	lastReceivedEmail.reset()
+	// create a folder to trigger the rule
+	folder := vfs.BaseVirtualFolder{
+		Name:       "ftest failed command",
+		MappedPath: filepath.Join(os.TempDir(), "p"),
+	}
+	folder, _, err = httpdtest.AddFolder(folder, http.StatusCreated)
+	assert.NoError(t, err)
+
+	assert.NoFileExists(t, outPath)
+	assert.Eventually(t, func() bool {
+		return lastReceivedEmail.get().From != ""
+	}, 3000*time.Millisecond, 100*time.Millisecond)
+	email := lastReceivedEmail.get()
+	assert.Len(t, email.To, 1)
+	assert.True(t, slices.Contains(email.To, "[email protected]"))
+	assert.Contains(t, email.Data, `Subject: Failed "add" from "admin"`)
+	assert.Contains(t, email.Data, fmt.Sprintf("Object name: %s object type: folder", folder.Name))
+	lastReceivedEmail.reset()
+
+	dataprovider.EnabledActionCommands = nil
+
+	_, err = httpdtest.RemoveFolder(folder, http.StatusOK)
+	assert.NoError(t, err)
+
+	_, err = httpdtest.RemoveEventRule(rule, http.StatusOK)
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveEventAction(action1, http.StatusOK)
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveEventAction(action2, http.StatusOK)
+	assert.NoError(t, err)
+	_, err = httpdtest.RemoveEventAction(action3, http.StatusOK)
+	assert.NoError(t, err)
+}
+
 func TestEventRuleProviderEvents(t *testing.T) {
 	if runtime.GOOS == osWindows {
 		t.Skip("this test is not available on Windows")

+ 4 - 0
internal/config/config.go

@@ -238,6 +238,9 @@ func Init() {
 			Metadata: common.MetadataConfig{
 				Read: 0,
 			},
+			EventManager: common.EventManagerConfig{
+				EnabledCommands: []string{},
+			},
 		},
 		ACME: acme.Configuration{
 			Email:      "",
@@ -2015,6 +2018,7 @@ func setViperDefaults() {
 	viper.SetDefault("common.umask", globalConf.Common.Umask)
 	viper.SetDefault("common.server_version", globalConf.Common.ServerVersion)
 	viper.SetDefault("common.metadata.read", globalConf.Common.Metadata.Read)
+	viper.SetDefault("common.event_manager.enabled_commands", globalConf.Common.EventManager.EnabledCommands)
 	viper.SetDefault("acme.email", globalConf.ACME.Email)
 	viper.SetDefault("acme.key_type", globalConf.ACME.KeyType)
 	viper.SetDefault("acme.certs_path", globalConf.ACME.CertsPath)

+ 15 - 0
internal/dataprovider/eventrule.go

@@ -23,6 +23,7 @@ import (
 	"net/http"
 	"path"
 	"path/filepath"
+	"slices"
 	"strings"
 	"time"
 
@@ -57,6 +58,9 @@ var (
 		ActionTypeBackup, ActionTypeUserQuotaReset, ActionTypeFolderQuotaReset, ActionTypeTransferQuotaReset,
 		ActionTypeDataRetentionCheck, ActionTypePasswordExpirationCheck, ActionTypeUserExpirationCheck,
 		ActionTypeUserInactivityCheck, ActionTypeIDPAccountCheck, ActionTypeRotateLogs}
+	// EnabledActionCommands defines the system commands that can be executed via EventManager,
+	// an empty list means that any command is allowed to be executed.
+	EnabledActionCommands []string
 )
 
 func isActionTypeValid(action int) bool {
@@ -449,6 +453,14 @@ func (c *EventActionHTTPConfig) GetHTTPClient() *http.Client {
 	return client
 }
 
+// IsActionCommandAllowed returns true if the specified command is allowed
+func IsActionCommandAllowed(cmd string) bool {
+	if len(EnabledActionCommands) == 0 {
+		return true
+	}
+	return slices.Contains(EnabledActionCommands, cmd)
+}
+
 // EventActionCommandConfig defines the configuration for a command event target
 type EventActionCommandConfig struct {
 	Cmd     string     `json:"cmd,omitempty"`
@@ -461,6 +473,9 @@ func (c *EventActionCommandConfig) validate() error {
 	if c.Cmd == "" {
 		return util.NewI18nError(util.NewValidationError("command is required"), util.I18nErrorCommandRequired)
 	}
+	if !IsActionCommandAllowed(c.Cmd) {
+		return util.NewValidationError(fmt.Sprintf("command %q is not allowed", c.Cmd))
+	}
 	if !filepath.IsAbs(c.Cmd) {
 		return util.NewI18nError(
 			util.NewValidationError("invalid command, it must be an absolute path"),

+ 11 - 0
internal/httpd/httpd_test.go

@@ -2383,6 +2383,17 @@ func TestEventActionValidation(t *testing.T) {
 	_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
 	assert.NoError(t, err)
 	assert.Contains(t, string(resp), "invalid command args")
+	action.Options.CmdConfig.Args = nil
+	// restrict commands
+	if runtime.GOOS == osWindows {
+		dataprovider.EnabledActionCommands = []string{"C:\\cmd.exe"}
+	} else {
+		dataprovider.EnabledActionCommands = []string{"/bin/sh"}
+	}
+	_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)
+	assert.NoError(t, err)
+	assert.Contains(t, string(resp), "is not allowed")
+	dataprovider.EnabledActionCommands = nil
 
 	action.Type = dataprovider.ActionTypeEmail
 	_, resp, err = httpdtest.AddEventAction(action, http.StatusBadRequest)

+ 17 - 15
internal/httpd/webadmin.go

@@ -291,13 +291,14 @@ type rolePage struct {
 
 type eventActionPage struct {
 	basePage
-	Action         dataprovider.BaseEventAction
-	ActionTypes    []dataprovider.EnumMapping
-	FsActions      []dataprovider.EnumMapping
-	HTTPMethods    []string
-	RedactedSecret string
-	Error          *util.I18nError
-	Mode           genericPageMode
+	Action          dataprovider.BaseEventAction
+	ActionTypes     []dataprovider.EnumMapping
+	FsActions       []dataprovider.EnumMapping
+	HTTPMethods     []string
+	EnabledCommands []string
+	RedactedSecret  string
+	Error           *util.I18nError
+	Mode            genericPageMode
 }
 
 type eventRulePage struct {
@@ -1079,14 +1080,15 @@ func (s *httpdServer) renderEventActionPage(w http.ResponseWriter, r *http.Reque
 	}
 
 	data := eventActionPage{
-		basePage:       s.getBasePageData(title, currentURL, r),
-		Action:         action,
-		ActionTypes:    dataprovider.EventActionTypes,
-		FsActions:      dataprovider.FsActionTypes,
-		HTTPMethods:    dataprovider.SupportedHTTPActionMethods,
-		RedactedSecret: redactedSecret,
-		Error:          getI18nError(err),
-		Mode:           mode,
+		basePage:        s.getBasePageData(title, currentURL, r),
+		Action:          action,
+		ActionTypes:     dataprovider.EventActionTypes,
+		FsActions:       dataprovider.FsActionTypes,
+		HTTPMethods:     dataprovider.SupportedHTTPActionMethods,
+		EnabledCommands: dataprovider.EnabledActionCommands,
+		RedactedSecret:  redactedSecret,
+		Error:           getI18nError(err),
+		Mode:            mode,
 	}
 	renderAdminTemplate(w, templateEventAction, data)
 }

+ 4 - 1
sftpgo.json

@@ -61,7 +61,10 @@
         "entries_soft_limit": 100,
         "entries_hard_limit": 150
       }
-    ]
+    ],
+    "event_manager": {
+      "enabled_commands": []
+    }
   },
   "acme": {
     "domains": [],

+ 13 - 0
templates/webadmin/eventaction.html

@@ -396,6 +396,18 @@ explicit grant from the SFTPGo Team ([email protected]).
                 </div>
             </div>
 
+            {{ if .EnabledCommands}}
+            <div class="form-group row action-type action-cmd mt-10">
+                <label for="idCmdPath" data-i18n="actions.types.command" class="col-md-3 col-form-label">Command</label>
+                <div class="col-md-9">
+                    <select id="idCmdPath" name="cmd_path" class="form-select" data-control="i18n-select2" data-hide-search="true">
+                        {{- range .EnabledCommands}}
+                        <option value="{{.}}" {{if eq $.Action.Options.CmdConfig.Cmd . }}selected{{end}}>{{.}}</option>
+                        {{- end}}
+                    </select>
+                </div>
+            </div>
+            {{- else}}
             <div class="form-group row action-type action-cmd mt-10">
                 <label for="idCmdPath" data-i18n="actions.types.command" class="col-md-3 col-form-label">Command</label>
                 <div class="col-md-9">
@@ -403,6 +415,7 @@ explicit grant from the SFTPGo Team ([email protected]).
                     <div id="idCmdPathHelp" class="form-text" data-i18n="actions.command_help"></div>
                 </div>
             </div>
+            {{- end}}
 
             <div class="form-group row action-type action-cmd mt-10">
                 <label for="idCommandArgs" data-i18n="actions.command_args" class="col-md-3 col-form-label">Arguments</label>