EventManager: allow to define the allowed system commands
Some checks failed
CI / Test and deploy (push) Has been cancelled
Code scanning - action / CodeQL-Build (push) Has been cancelled
CI / Test build flags (push) Has been cancelled
CI / Test with PgSQL/MySQL/Cockroach (push) Has been cancelled
CI / Build Linux packages (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Docker / Build (push) Has been cancelled

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino 2024-11-09 19:14:45 +01:00
parent 1df1b8e4b5
commit 5c163ed592
No known key found for this signature in database
GPG key ID: 935D2952DEC4EECF
10 changed files with 260 additions and 17 deletions

View file

@ -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

View file

@ -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

View file

@ -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 {

View file

@ -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")

View file

@ -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)

View file

@ -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"),

View file

@ -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)

View file

@ -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)
}

View file

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

View file

@ -396,6 +396,18 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
</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 (support@sftpgo.com).
<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>