diff --git a/internal/common/common.go b/internal/common/common.go index 7c430a99..9933f041 100644 --- a/internal/common/common.go +++ b/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 diff --git a/internal/common/common_test.go b/internal/common/common_test.go index a3e04a92..46f66aec 100644 --- a/internal/common/common_test.go +++ b/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 diff --git a/internal/common/eventmanager.go b/internal/common/eventmanager.go index fe4dbb9d..ed07de68 100644 --- a/internal/common/eventmanager.go +++ b/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 { diff --git a/internal/common/protocol_test.go b/internal/common/protocol_test.go index 88fb946f..abc7dd02 100644 --- a/internal/common/protocol_test.go +++ b/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: "notification@example.com", + 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{"test3@example.com"}, + 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{"failure@example.com"}, + 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, "failure@example.com")) + 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") diff --git a/internal/config/config.go b/internal/config/config.go index 2bb54b0a..00f8e1ba 100644 --- a/internal/config/config.go +++ b/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) diff --git a/internal/dataprovider/eventrule.go b/internal/dataprovider/eventrule.go index 9c3d3bd3..18419966 100644 --- a/internal/dataprovider/eventrule.go +++ b/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"), diff --git a/internal/httpd/httpd_test.go b/internal/httpd/httpd_test.go index c597e295..45fcbc36 100644 --- a/internal/httpd/httpd_test.go +++ b/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) diff --git a/internal/httpd/webadmin.go b/internal/httpd/webadmin.go index 00b3a696..34383b33 100644 --- a/internal/httpd/webadmin.go +++ b/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) } diff --git a/sftpgo.json b/sftpgo.json index 32311621..4998cce3 100644 --- a/sftpgo.json +++ b/sftpgo.json @@ -61,7 +61,10 @@ "entries_soft_limit": 100, "entries_hard_limit": 150 } - ] + ], + "event_manager": { + "enabled_commands": [] + } }, "acme": { "domains": [], diff --git a/templates/webadmin/eventaction.html b/templates/webadmin/eventaction.html index 6d35fe16..37690125 100644 --- a/templates/webadmin/eventaction.html +++ b/templates/webadmin/eventaction.html @@ -396,6 +396,18 @@ explicit grant from the SFTPGo Team (support@sftpgo.com). + {{ if .EnabledCommands}} +
+ +
+ +
+
+ {{- else}}
@@ -403,6 +415,7 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
+ {{- end}}