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.
404 lines
11 KiB
Go
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
|
|
}
|