CVE-2025-24366
Description
SFTPGo is an open source, event-driven file transfer solution. SFTPGo supports execution of a defined set of commands via SSH. Besides a set of default commands some optional commands can be activated, one of them being rsync. It is disabled in the default configuration and it is limited to the local filesystem, it does not work with cloud/remote storage backends. Due to missing sanitization of the client provided rsync command, an authenticated remote user can use some options of the rsync command to read or write files with the permissions of the SFTPGo server process. This issue was fixed in version v2.6.5 by checking the client provided arguments. Users are advised to upgrade. There are no known workarounds for this vulnerability.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/drakkan/sftpgo/v2Go | >= 0.9.5, < 2.6.5 | 2.6.5 |
github.com/drakkan/sftpgoGo | <= 1.2.2 | — |
Patches
2d924811e1fe6b347ab6051f6rsync: enforce a supported format and limit the allowed options
2 files changed · +135 −5
internal/sftpd/internal_test.go+49 −3 modified@@ -842,7 +842,7 @@ func TestRsyncOptions(t *testing.T) { } cmd, err := sshCmd.getSystemCommand() assert.NoError(t, err) - assert.True(t, util.Contains(cmd.cmd.Args, "--safe-links"), + assert.Equal(t, []string{"rsync", "--server", "-vlogDtprze.iLsfxC", "--safe-links", ".", user.HomeDir + "/"}, cmd.cmd.Args, "--safe-links must be added if the user has the create symlinks permission") permissions["/"] = []string{dataprovider.PermDownload, dataprovider.PermUpload, dataprovider.PermCreateDirs, @@ -852,15 +852,21 @@ func TestRsyncOptions(t *testing.T) { conn = &Connection{ BaseConnection: common.NewBaseConnection("", common.ProtocolSFTP, "", "", user), } + sshCmd = sshCommand{ + command: "rsync", + connection: conn, + } + _, err = sshCmd.getSystemCommand() + assert.Error(t, err) sshCmd = sshCommand{ command: "rsync", connection: conn, args: []string{"--server", "-vlogDtprze.iLsfxC", ".", "/"}, } cmd, err = sshCmd.getSystemCommand() assert.NoError(t, err) - assert.True(t, util.Contains(cmd.cmd.Args, "--munge-links"), - "--munge-links must be added if the user has the create symlinks permission") + assert.Equal(t, []string{"rsync", "--server", "-vlogDtprze.iLsfxC", "--munge-links", ".", user.HomeDir + "/"}, cmd.cmd.Args, + "--munge-links must be added if the user hasn't the create symlinks permission") sshCmd.connection.User.VirtualFolders = append(sshCmd.connection.User.VirtualFolders, vfs.VirtualFolder{ BaseVirtualFolder: vfs.BaseVirtualFolder{ @@ -2227,3 +2233,43 @@ func TestAuthenticationErrors(t *testing.T) { assert.ErrorIs(t, err, sftpAuthError) assert.NotErrorIs(t, err, util.ErrNotFound) } + +func TestRsyncArguments(t *testing.T) { + assert.False(t, canAcceptRsyncArgs(nil)) + args := []string{"-e", "--server"} + assert.False(t, canAcceptRsyncArgs(args)) + args = []string{"--server", "--sender", "-vlogDtpre.iLsfxCIvu", ".", "."} + assert.True(t, canAcceptRsyncArgs(args)) + args = []string{"--server", "--sender", "--server", "-vlogDtpre.iLsfxCIvu", ".", "."} + assert.False(t, canAcceptRsyncArgs(args)) + args = []string{"--server", "..", "/"} + assert.False(t, canAcceptRsyncArgs(args)) + args = []string{"--server", ".", "/"} + assert.False(t, canAcceptRsyncArgs(args)) + args = []string{"--server", "--sender", "-vlogDtpre.iLsfxCIvu", ".", "."} + assert.True(t, canAcceptRsyncArgs(args)) + args = []string{"--server", "--sender", "-vlogDtpre.iLsfxCIvu", "--delete", ".", "/"} + assert.True(t, canAcceptRsyncArgs(args)) + args = []string{"--server", "-vlogDtpre.iLsfxCIvu", "--delete", ".", "/"} + assert.True(t, canAcceptRsyncArgs(args)) + args = []string{"--server", "-vlogDtpre.iLsfxCIvu", "--delete", "/", ".", "/"} + assert.False(t, canAcceptRsyncArgs(args)) + args = []string{"--server", "--sender", "-vlogDtpre.iLsfxCIvu", ".", "path1", "path2"} + assert.False(t, canAcceptRsyncArgs(args)) + args = []string{"--server", "--sender", "-vlogDtpre.iLsfxCIvu", "."} + assert.False(t, canAcceptRsyncArgs(args)) + args = []string{"--sender", "-vlogDtpre.iLsfxCIvu", "--delete", ".", "/"} + assert.False(t, canAcceptRsyncArgs(args)) + args = []string{"--server", "-vlogDtpre.", "--delete", ".", "/"} + assert.False(t, canAcceptRsyncArgs(args)) + args = []string{"--server", "--sender", "-vlogDtpre.", "--delete", ".", "/"} + assert.False(t, canAcceptRsyncArgs(args)) + args = []string{"--server", "--sender", "-e.iLsfxCIvu", ".", "/"} + assert.True(t, canAcceptRsyncArgs(args)) + args = []string{"--server", "-vlogDtpre.iLsfxCIvu", "--delete", "/"} + assert.False(t, canAcceptRsyncArgs(args)) + args = []string{"--server", "-vlogDtpre.iLsfxCIvu", "--delete", "--safe-links"} + assert.False(t, canAcceptRsyncArgs(args)) + args = []string{"--server", "-vlogDtpre.iLsfxCIvu", "--unsupported-option", ".", "/"} + assert.False(t, canAcceptRsyncArgs(args)) +}
internal/sftpd/ssh_cmd.go+86 −2 modified@@ -27,6 +27,7 @@ import ( "os/exec" "path" "runtime/debug" + "slices" "strings" "sync" "time" @@ -425,6 +426,10 @@ func (c *sshCommand) getSystemCommand() (systemCommand, error) { return command, errUnsupportedConfig } if c.command == "rsync" { + if !canAcceptRsyncArgs(args) { + c.connection.Log(logger.LevelWarn, "invalid rsync command, args: %+v", args) + return command, errors.New("invalid or unsupported rsync command") + } // we cannot avoid that rsync creates symlinks so if the user has the permission // to create symlinks we add the option --safe-links to the received rsync command if // it is not already set. This should prevent to create symlinks that point outside @@ -433,11 +438,11 @@ func (c *sshCommand) getSystemCommand() (systemCommand, error) { // already set. This should make symlinks unusable (but manually recoverable) if c.connection.User.HasPerm(dataprovider.PermCreateSymlinks, c.getDestPath()) { if !util.Contains(args, "--safe-links") { - args = append([]string{"--safe-links"}, args...) + args = slices.Insert(args, len(args)-2, "--safe-links") } } else { if !util.Contains(args, "--munge-links") { - args = append([]string{"--munge-links"}, args...) + args = slices.Insert(args, len(args)-2, "--munge-links") } } } @@ -454,6 +459,85 @@ func (c *sshCommand) getSystemCommand() (systemCommand, error) { return command, nil } +var ( + acceptedRsyncOptions = []string{ + "--existing", + "--ignore-existing", + "--remove-source-files", + "--delete", + "--delete-before", + "--delete-during", + "--delete-delay", + "--delete-after", + "--delete-excluded", + "--ignore-errors", + "--force", + "--partial", + "--delay-updates", + "--size-only", + "--blocking-io", + "--stats", + "--progress", + "--list-only", + "--dry-run", + } +) + +func canAcceptRsyncArgs(args []string) bool { + // We support the following formats: + // + // rsync --server -vlogDtpre.iLsfxCIvu --supported-options . ARG # push + // rsync --server --sender -vlogDtpre.iLsfxCIvu --supported-options . ARG # pull + // + // Then some options with a single dash and containing "e." followed by + // supported options, listed in acceptedRsyncOptions, with double dash then + // dot and a finally single argument specifying the path to operate on. + idx := 0 + if len(args) < 4 { + return false + } + // The first argument must be --server. + if args[idx] != "--server" { + return false + } + idx++ + // The second argument must be --sender or an argument starting with a + // single dash and containing "e." + if args[idx] == "--sender" { + idx++ + } + // Check that this argument starts with a dash and contains e. but does not + // end with e. + if !strings.HasPrefix(args[idx], "-") || strings.HasPrefix(args[idx], "--") || + !strings.Contains(args[idx], "e.") || strings.HasSuffix(args[idx], "e.") { + return false + } + idx++ + // We now expect optional supported options like --delete or a dot followed + // by the path to operate on. We don't support multiple paths in sender + // mode. + if len(args) < idx+2 { + return false + } + // A dot is required we'll check the expected position later. + if !slices.Contains(args, ".") { + return false + } + for _, arg := range args[idx:] { + if slices.Contains(acceptedRsyncOptions, arg) { + idx++ + } else { + if arg == "." { + idx++ + break + } + // Unsupported argument. + return false + } + } + return len(args) == idx+1 +} + // for the supported commands, the destination path, if any, is the last argument func (c *sshCommand) getDestPath() string { if len(c.args) == 0 {
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.