Files
rclone/cmd/serve/sftp/connection.go
Nick Craig-Wood 50b4d4c745 serve sftp: don't crash the whole server on a bad request GHSA-6jcg-q3wp-x2f4
Three ways a single client could deny service to everyone else connected to
the same serve sftp process:

A session "subsystem" request payload is a length-prefixed string, but it was
sliced at a fixed offset without checking its length, so a client sending a
truncated payload panicked the out-of-band request goroutine and killed the
process. Decode the payload instead, the way the neighbouring "exec" request
already does.

Rejecting a request then left the goroutine handling that channel waiting
forever to learn what kind of channel it was, because nothing was ever sent
on the channel it waits on. A client could open channels in a loop making
unsupported requests and grow the server's goroutines and memory without
bound. Signal the waiter when the requests run out so the channel is torn
down.

Separately, the request handlers - and reads, writes and closes on the file
handles they return - run on pkg/sftp packet worker goroutines which have no
panic recovery. A panic raised by a backend while serving one request took the
process down with it. Recover panics at that boundary, log them with a stack
trace, and return them to the requesting client as an error instead.

Addresses GHSA-6jcg-q3wp-x2f4.
2026-07-31 13:21:59 +01:00

404 lines
11 KiB
Go

//go:build !plan9
package sftp
import (
"context"
"errors"
"fmt"
"io"
"net"
"os"
"regexp"
"strings"
"github.com/pkg/sftp"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/hash"
"github.com/rclone/rclone/lib/terminal"
"github.com/rclone/rclone/vfs"
"github.com/rclone/rclone/vfs/vfscommon"
"golang.org/x/crypto/ssh"
)
func describeConn(c interface {
RemoteAddr() net.Addr
LocalAddr() net.Addr
}) string {
return fmt.Sprintf("serve sftp %s->%s", c.RemoteAddr(), c.LocalAddr())
}
// Return the exit status of the command
type exitStatus struct {
RC uint32
}
// The incoming exec command
type execCommand struct {
Command string
}
var shellUnEscapeRegex = regexp.MustCompile(`\\(.)`)
// Unescape a string that was escaped by rclone
func shellUnEscape(str string) string {
str = strings.ReplaceAll(str, "'\n'", "\n")
str = shellUnEscapeRegex.ReplaceAllString(str, `$1`)
return str
}
// Info about the current connection
type conn struct {
vfs *vfs.VFS
handlers sftp.Handlers
what string
}
// execCommand implements an extremely limited number of commands to
// interoperate with the rclone sftp backend
func (c *conn) execCommand(ctx context.Context, out io.Writer, command string) (err error) {
defer recoverPanic(&err)
binary, args := command, ""
before, after, ok := strings.Cut(command, " ")
if ok {
binary = before
args = strings.TrimLeft(after, " ")
}
args = shellUnEscape(args)
fs.Debugf(c.what, "exec command: binary = %q, args = %q", binary, args)
switch binary {
case "df":
about := c.vfs.Fs().Features().About
if about == nil {
return errors.New("df not supported")
}
usage, err := about(ctx)
if err != nil {
return fmt.Errorf("about failed: %w", err)
}
total, used, free := int64(-1), int64(-1), int64(-1)
if usage.Total != nil {
total = *usage.Total / 1024
}
if usage.Used != nil {
used = *usage.Used / 1024
}
if usage.Free != nil {
free = *usage.Free / 1024
}
perc := int64(0)
if total > 0 && used >= 0 {
perc = (100 * used) / total
}
_, err = fmt.Fprintf(out, ` Filesystem 1K-blocks Used Available Use%% Mounted on
/dev/root %d %d %d %d%% /
`, total, used, free, perc)
if err != nil {
return fmt.Errorf("send output failed: %w", err)
}
case "md5sum":
return c.handleHashsumCommand(ctx, out, hash.MD5, args)
case "sha1sum":
return c.handleHashsumCommand(ctx, out, hash.SHA1, args)
case "crc32":
return c.handleHashsumCommand(ctx, out, hash.CRC32, args)
case "sha256sum":
return c.handleHashsumCommand(ctx, out, hash.SHA256, args)
case "b3sum":
return c.handleHashsumCommand(ctx, out, hash.BLAKE3, args)
case "xxh128sum":
return c.handleHashsumCommand(ctx, out, hash.XXH128, args)
case "xxhsum":
argv := strings.SplitN(args, " ", 2)
if len(argv) == 0 || argv[0] != "-H2" {
return fmt.Errorf("%q not implemented", command)
}
if len(argv) > 1 {
args = argv[1]
} else {
args = ""
}
return c.handleHashsumCommand(ctx, out, hash.XXH128, args)
case "rclone":
argv := strings.SplitN(args, " ", 3)
if len(argv) > 1 && argv[0] == "hashsum" {
var ht hash.Type
if err := ht.Set(argv[1]); err != nil {
return err
}
if len(argv) > 2 {
args = argv[2]
} else {
args = ""
}
return c.handleHashsumCommand(ctx, out, ht, args)
}
return fmt.Errorf("%q not implemented", command)
case "echo":
// Special cases for legacy rclone command detection.
// Before rclone v1.49.0 the sftp backend used "echo 'abc' | md5sum" when
// detecting hash support, but was then changed to instead just execute
// md5sum/sha1sum (without arguments), which is handled above. The following
// code is therefore only necessary to support rclone versions older than
// v1.49.0 using a sftp remote connected to a rclone serve sftp instance
// running a newer version of rclone (e.g. latest).
switch args {
case "'abc' | md5sum":
if c.vfs.Fs().Hashes().Contains(hash.MD5) {
_, err = fmt.Fprintf(out, "0bee89b07a248e27c83fc3d5951213c1 -\n")
if err != nil {
return fmt.Errorf("send output failed: %w", err)
}
} else {
return errors.New("md5 hash not supported")
}
case "'abc' | sha1sum":
if c.vfs.Fs().Hashes().Contains(hash.SHA1) {
_, err = fmt.Fprintf(out, "03cfd743661f07975fa2f1220c5194cbaff48451 -\n")
if err != nil {
return fmt.Errorf("send output failed: %w", err)
}
} else {
return errors.New("sha1 hash not supported")
}
default:
_, err = fmt.Fprintf(out, "%s\n", args)
if err != nil {
return fmt.Errorf("send output failed: %w", err)
}
}
default:
return fmt.Errorf("%q not implemented", command)
}
return nil
}
// handleHashsumCommand is a helper to execCommand for common functionality of hashsum related commands
func (c *conn) handleHashsumCommand(ctx context.Context, out io.Writer, ht hash.Type, args string) (err error) {
if !c.vfs.Fs().Hashes().Contains(ht) {
return fmt.Errorf("%v hash not supported", ht)
}
var hashSum string
if args == "" {
// empty hash for no input
switch ht {
case hash.MD5:
hashSum = "d41d8cd98f00b204e9800998ecf8427e"
case hash.SHA1:
hashSum = "da39a3ee5e6b4b0d3255bfef95601890afd80709"
case hash.CRC32:
hashSum = "00000000"
case hash.SHA256:
hashSum = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
case hash.BLAKE3:
hashSum = "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262"
case hash.XXH3:
hashSum = "2d06800538d394c2"
case hash.XXH128:
hashSum = "99aa06d3014798d86001c324468d497f"
default:
return fmt.Errorf("%v hash not implemented", ht)
}
args = "-"
} else {
node, err := c.vfs.Stat(args)
if err != nil {
return fmt.Errorf("hash failed finding file %q: %w", args, err)
}
if node.IsDir() {
return errors.New("can't hash directory")
}
o, ok := node.DirEntry().(fs.ObjectInfo)
if !ok {
fs.Debugf(args, "File uploading - reading hash from VFS cache")
in, err := node.Open(os.O_RDONLY)
if err != nil {
return fmt.Errorf("hash vfs open failed: %w", err)
}
defer func() {
_ = in.Close()
}()
h, err := hash.NewMultiHasherTypes(hash.NewHashSet(ht))
if err != nil {
return fmt.Errorf("hash vfs create multi-hasher failed: %w", err)
}
_, err = io.Copy(h, in)
if err != nil {
return fmt.Errorf("hash vfs copy failed: %w", err)
}
hashSum = h.Sums()[ht]
} else {
hashSum, err = o.Hash(ctx, ht)
if err != nil {
return fmt.Errorf("hash failed: %w", err)
}
}
}
_, err = fmt.Fprintf(out, "%s %s\n", hashSum, args)
if err != nil {
return fmt.Errorf("send output failed: %w", err)
}
return nil
}
// handle a new incoming channel request
func (c *conn) handleChannel(newChannel ssh.NewChannel) {
fs.Debugf(c.what, "Incoming channel: %s\n", newChannel.ChannelType())
if newChannel.ChannelType() != "session" {
err := newChannel.Reject(ssh.UnknownChannelType, "unknown channel type")
fs.Debugf(c.what, "Unknown channel type: %s\n", newChannel.ChannelType())
if err != nil {
fs.Errorf(c.what, "Failed to reject unknown channel: %v", err)
}
return
}
channel, requests, err := newChannel.Accept()
if err != nil {
fs.Errorf(c.what, "could not accept channel: %v", err)
return
}
defer func() {
err := channel.Close()
if err != nil && err != io.EOF {
fs.Debugf(c.what, "Failed to close channel: %v", err)
}
}()
fs.Debugf(c.what, "Channel accepted\n")
isSFTP := make(chan bool, 1)
var command execCommand
// Handle out-of-band requests
go func(in <-chan *ssh.Request) {
// Unblock the main routine when the requests run out, otherwise a
// channel which never makes a supported request - because it was
// rejected, or was closed straight away - leaks this goroutine and
// the channel for the lifetime of the connection.
defer close(isSFTP)
for req := range in {
fs.Debugf(c.what, "Request: %v\n", req.Type)
ok := false
var subSystemIsSFTP bool
var reply []byte
switch req.Type {
case "subsystem":
// The payload is a length-prefixed string, so it must be
// decoded rather than sliced to avoid panics if too short.
var subsystem struct{ Name string }
if err := ssh.Unmarshal(req.Payload, &subsystem); err != nil {
fs.Errorf(c.what, "ignoring bad subsystem request: %v", err)
break
}
fs.Debugf(c.what, "Subsystem: %s\n", subsystem.Name)
if subsystem.Name == "sftp" {
ok = true
subSystemIsSFTP = true
}
case "exec":
err := ssh.Unmarshal(req.Payload, &command)
if err != nil {
fs.Errorf(c.what, "ignoring bad exec command: %v", err)
} else {
ok = true
subSystemIsSFTP = false
}
}
fs.Debugf(c.what, " - accepted: %v\n", ok)
err := req.Reply(ok, reply)
if err != nil {
fs.Errorf(c.what, "Failed to Reply to request: %v", err)
return
}
if ok {
// Wake up main routine after we have responded
isSFTP <- subSystemIsSFTP
}
}
}(requests)
// Wait for either subsystem "sftp" or "exec" request
subSystemIsSFTP, ok := <-isSFTP
if !ok {
fs.Debugf(c.what, "Channel closed without a supported request")
return
}
if subSystemIsSFTP {
if err := serveChannel(channel, c.handlers, c.what); err != nil {
fs.Errorf(c.what, "Failed to serve SFTP: %v", err)
}
} else {
var rc = uint32(0)
err := c.execCommand(context.TODO(), channel, command.Command)
if err != nil {
rc = 1
_, errPrint := fmt.Fprintf(channel.Stderr(), "%v\n", err)
if errPrint != nil {
fs.Errorf(c.what, "Failed to write to stderr: %v", errPrint)
}
fs.Debugf(c.what, "command %q failed with error: %v", command.Command, err)
}
_, err = channel.SendRequest("exit-status", false, ssh.Marshal(exitStatus{RC: rc}))
if err != nil {
fs.Errorf(c.what, "Failed to send exit status: %v", err)
}
}
}
// Service the incoming Channel channel in go routine
func (c *conn) handleChannels(chans <-chan ssh.NewChannel) {
for newChannel := range chans {
go c.handleChannel(newChannel)
}
}
func serveChannel(rwc io.ReadWriteCloser, h sftp.Handlers, what string) error {
fs.Debugf(what, "Starting SFTP server")
server := sftp.NewRequestServer(rwc, h)
defer func() {
err := server.Close()
if err != nil && err != io.EOF {
fs.Debugf(what, "Failed to close server: %v", err)
}
}()
err := server.Serve()
if err != nil && err != io.EOF {
return fmt.Errorf("completed with error: %w", err)
}
fs.Debugf(what, "exited session")
return nil
}
func serveStdio(f fs.Fs) error {
if terminal.IsTerminal(int(os.Stdout.Fd())) {
return errors.New("refusing to run SFTP server directly on a terminal. Please let sshd start rclone, by connecting with sftp or sshfs")
}
sshChannel := &stdioChannel{
stdin: os.Stdin,
stdout: os.Stdout,
}
handlers := newVFSHandler(vfs.New(context.Background(), f, &vfscommon.Opt))
return serveChannel(sshChannel, handlers, "stdio")
}
type stdioChannel struct {
stdin *os.File
stdout *os.File
}
func (c *stdioChannel) Read(data []byte) (int, error) {
return c.stdin.Read(data)
}
func (c *stdioChannel) Write(data []byte) (int, error) {
return c.stdout.Write(data)
}
func (c *stdioChannel) Close() error {
err1 := c.stdin.Close()
err2 := c.stdout.Close()
if err1 != nil {
return err1
}
return err2
}