Files
teleport/session/reexec/reexecsftp/sftp.go
T
2026-08-25 11:35:10 -05:00

478 lines
12 KiB
Go

/*
* Teleport
* Copyright (C) 2023 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package reexecsftp
import (
"bufio"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"os"
"path"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"github.com/gravitational/trace"
"github.com/pkg/sftp"
"golang.org/x/sys/unix"
hostuser "github.com/gravitational/teleport/session/host/user"
"github.com/gravitational/teleport/session/reexec/safefile"
"github.com/gravitational/teleport/session/sftputils"
)
type compositeCh struct {
r io.ReadCloser
w io.WriteCloser
}
func (c compositeCh) Read(p []byte) (int, error) {
return c.r.Read(p)
}
func (c compositeCh) Write(p []byte) (int, error) {
return c.w.Write(p)
}
func (c compositeCh) Close() error {
return trace.NewAggregate(c.r.Close(), c.w.Close())
}
type allowedOps struct {
write bool
path string
}
// sftpHandler provides handlers for a SFTP server.
type sftpHandler struct {
logger *slog.Logger
allowed *allowedOps
// mtx protects files
mtx sync.Mutex
files []*sftputils.TrackedFile
events chan<- sftputils.Event
}
func newSFTPHandler(logger *slog.Logger, req *FileTransferRequest, events chan<- sftputils.Event) (*sftpHandler, error) {
var allowed *allowedOps
if req != nil {
allowed = &allowedOps{
write: !req.Download,
}
allowedPath, err := sftputils.ExpandHomeDir(filepath.ToSlash(req.Location))
if err != nil {
return nil, trace.Wrap(err)
}
if !path.IsAbs(allowedPath) {
return nil, trace.BadParameter("allowed path must be absolute")
}
allowed.path = path.Clean(allowedPath)
}
return &sftpHandler{
logger: logger,
allowed: allowed,
events: events,
}, nil
}
// ensureReqIsAllowed returns an error if the SFTP request isn't
// allowed based on the approved file transfer request for this session.
func (s *sftpHandler) ensureReqIsAllowed(req *sftp.Request) error {
// no specifically allowed operations, all requests are allowed
if s.allowed == nil {
return nil
}
isDir, err := pathIsDir(req.Filepath)
if err != nil {
return trace.Wrap(err)
}
if isDir {
return trace.Errorf("destination path %s is a directory, it must be a file", req.Filepath)
}
cleaned := path.Clean(filepath.ToSlash(req.Filepath))
if s.allowed.path != cleaned {
return trace.Errorf("operations are only allowed on %s, not %s", s.allowed.path, cleaned)
}
switch req.Method {
case sftputils.MethodLstat, sftputils.MethodStat:
// these methods are allowed
case sftputils.MethodGet:
// only allow reads for downloads
if s.allowed.write {
return trace.Errorf("reading is not allowed for this request")
}
case sftputils.MethodPut, sftputils.MethodSetStat:
// only allow writes and chmods for uploads
if !s.allowed.write {
return trace.Errorf("writing is not allowed for this request")
}
default:
return trace.Errorf("method %s is not allowed on %s", strings.ToLower(req.Method), req.Filepath)
}
return nil
}
func pathIsDir(path string) (bool, error) {
fi, err := os.Stat(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
return false, trace.Wrap(err)
}
return fi.IsDir(), nil
}
// OpenFile handles Open requests for both reading and writing. Required to
// satisfy [sftp.OpenFileWriter].
func (s *sftpHandler) OpenFile(req *sftp.Request) (_ sftp.WriterAtReaderAt, retErr error) {
defer s.sendSFTPEvent(req, retErr)
if req.Filepath == "" {
return nil, os.ErrInvalid
}
return s.openFile(req)
}
// Fileread handles 'open' requests when opening a file for reading
// is desired.
func (s *sftpHandler) Fileread(req *sftp.Request) (_ io.ReaderAt, retErr error) {
defer s.sendSFTPEvent(req, retErr)
if req.Filepath == "" {
return nil, os.ErrInvalid
}
if !req.Pflags().Read {
return nil, os.ErrInvalid
}
return s.openFile(req)
}
// Filewrite handles 'open' requests when opening a file for writing
// is desired.
func (s *sftpHandler) Filewrite(req *sftp.Request) (_ io.WriterAt, retErr error) {
defer s.sendSFTPEvent(req, retErr)
if req.Filepath == "" {
return nil, os.ErrInvalid
}
if !req.Pflags().Write {
return nil, os.ErrInvalid
}
return s.openFile(req)
}
func (s *sftpHandler) openFile(req *sftp.Request) (sftp.WriterAtReaderAt, error) {
if err := s.ensureReqIsAllowed(req); err != nil {
return nil, err
}
flags := sftputils.ParseFlags(req)
var f *os.File
var err error
if s.allowed != nil {
// Files in moderated sessions may not include symlinks.
f, err = safefile.OpenFileNoFollow(req.Filepath, flags, 0o644)
} else {
f, err = os.OpenFile(req.Filepath, flags, 0o644)
}
if err != nil {
// Symlink traversal is not allowed for moderated file transfers.
if s.allowed != nil && errors.Is(err, syscall.ELOOP) {
return nil, trace.Errorf("following symlinks is not allowed for moderated file transfers, request the resolved path instead")
}
return nil, err
}
trackFile := &sftputils.TrackedFile{File: f}
s.mtx.Lock()
s.files = append(s.files, trackFile)
s.mtx.Unlock()
return trackFile, nil
}
// Filecmd handles file modification requests.
func (s *sftpHandler) Filecmd(req *sftp.Request) (retErr error) {
defer func() {
if errors.Is(retErr, sftp.ErrSSHFxOpUnsupported) {
return
}
s.sendSFTPEvent(req, retErr)
}()
if req.Filepath == "" {
return os.ErrInvalid
}
if err := s.ensureReqIsAllowed(req); err != nil {
return err
}
if s.allowed != nil && req.Method == sftputils.MethodSetStat {
// Setstat can be called during moderated file transfers, don't follow
// symlinks if that's the case.
return setstatNoFollow(req.Filepath, req.AttrFlags(), req.Attributes())
}
return sftputils.HandleFilecmd(req, nil /* local filesystem */)
}
// Filelist handles 'readdir', 'stat' and 'readlink' requests.
func (s *sftpHandler) Filelist(req *sftp.Request) (_ sftp.ListerAt, retErr error) {
defer func() {
if req.Method == sftputils.MethodList {
s.sendSFTPEvent(req, retErr)
}
}()
if req.Filepath == "" {
return nil, os.ErrInvalid
}
if err := s.ensureReqIsAllowed(req); err != nil {
return nil, err
}
return sftputils.HandleFilelist(req, nil /* local filesystem */)
}
// RealPath canonicalizes a path name, including resolving ".." and
// following symlinks. Required to implement [sftp.RealPathFileLister].
func (s *sftpHandler) RealPath(path string) (string, error) {
return sftputils.Realpath(path)
}
func (s *sftpHandler) sendSFTPEvent(req *sftp.Request, reqErr error) {
wd, err := os.Getwd()
if err != nil {
s.logger.WarnContext(req.Context(), "Failed to get working dir", "error", err)
// Emit event without working directory.
}
event, err := sftputils.ParseSFTPEvent(req, wd, reqErr)
if err != nil {
s.logger.WarnContext(req.Context(), "Unknown SFTP request", "request", req.Method)
return
} else if reqErr != nil {
s.logger.DebugContext(req.Context(), "failed handling SFTP request", "request", req.Method, "error", reqErr)
}
s.events <- sftputils.Event{SFTP: event}
}
func RunSFTP(logger *slog.Logger) error {
chr, err := openFD(3, "chr")
if err != nil {
return trace.Wrap(err)
}
defer chr.Close()
chw, err := openFD(4, "chw")
if err != nil {
return trace.Wrap(err)
}
defer chw.Close()
auditFile, err := openFD(5, "audit")
if err != nil {
return trace.Wrap(err)
}
defer auditFile.Close()
currentUser, err := hostuser.Current()
if err != nil {
return trace.Wrap(err)
}
_, err = os.Stat(currentUser.HomeDir)
if err != nil {
return trace.Wrap(err)
}
// Read the file transfer request for this session if one exists
bufferedReader := bufio.NewReader(chr)
var encodedReq []byte
var fileTransferReq *FileTransferRequest
for {
b, err := bufferedReader.ReadByte()
if err != nil {
return trace.Wrap(err)
}
// the encoded request will end with a null byte
if b == 0x0 {
break
}
encodedReq = append(encodedReq, b)
}
if len(encodedReq) != 0 {
fileTransferReq = new(FileTransferRequest)
if err := json.Unmarshal(encodedReq, fileTransferReq); err != nil {
return trace.Wrap(err)
}
}
ch := compositeCh{io.NopCloser(bufferedReader), chw}
sftpEvents := make(chan sftputils.Event, 1)
h, err := newSFTPHandler(logger, fileTransferReq, sftpEvents)
if err != nil {
return trace.Wrap(err)
}
handler := sftp.Handlers{
FileGet: h,
FilePut: h,
FileCmd: h,
FileList: h,
}
sftpSrv := sftp.NewRequestServer(ch, handler, sftp.WithStartDirectory(currentUser.HomeDir))
ctx := context.TODO()
// Start a goroutine to marshal and send audit events to the parent
// process to avoid blocking the SFTP connection on event handling
done := make(chan struct{})
go func() {
enc := json.NewEncoder(auditFile)
enc.SetEscapeHTML(false)
for event := range sftpEvents {
err := enc.Encode(event)
if err != nil {
logger.WarnContext(ctx, "Failed to send SFTP event to parent", "error", err)
}
}
close(done)
}()
serveErr := sftpSrv.Serve()
if errors.Is(serveErr, io.EOF) {
serveErr = nil
} else {
serveErr = trace.Wrap(serveErr)
}
// Send a summary event last
summaryEvent := &sftputils.SFTPSummaryEvent{
Time: time.Now().UnixNano(),
Stats: make([]sftputils.SFTPSummaryEventFileTransferStat, 0, len(h.files)),
}
// We don't need to worry about closing these files, handler will
// take care of that for us
for _, f := range h.files {
summaryEvent.Stats = append(summaryEvent.Stats, sftputils.SFTPSummaryEventFileTransferStat{
Path: f.Name(),
Read: f.BytesRead(),
Written: f.BytesWritten(),
})
}
sftpEvents <- sftputils.Event{Summary: summaryEvent}
// Wait until event marshaling goroutine is finished
close(sftpEvents)
<-done
return trace.NewAggregate(serveErr, sftpSrv.Close())
}
func openFD(fd uintptr, name string) (*os.File, error) {
ret, err := unix.FcntlInt(fd, unix.F_GETFD, 0)
if err != nil {
return nil, trace.Wrap(err)
}
if ret != 0 {
return nil, trace.BadParameter("'%s sftp' should not be run directly. It will be executed by Teleport when SFTP connections are received.", os.Args[0])
}
file := os.NewFile(fd, name)
if file == nil {
return nil, trace.NotFound("inherited file %s not found", name)
}
return file, nil
}
func chtimes(file *os.File, atime, mtime time.Time) error {
syscallConn, err := file.SyscallConn()
if err != nil {
return err
}
var modifyErr error
ctrlErr := syscallConn.Control(func(fd uintptr) {
for {
modifyErr = unix.Futimes(int(fd), []unix.Timeval{
unix.NsecToTimeval(atime.UnixNano()),
unix.NsecToTimeval(mtime.UnixNano()),
})
if !errors.Is(modifyErr, syscall.EINTR) {
return
}
}
})
if ctrlErr != nil {
return ctrlErr
}
return modifyErr
}
// setstatNoFollow sets file attributes on a file without following any symlinks.
func setstatNoFollow(file string, attrFlags sftp.FileAttrFlags, attrs *sftp.FileStat) error {
if !attrFlags.Acmodtime && !attrFlags.Permissions && !attrFlags.Size && !attrFlags.UidGid {
return nil
}
mode := os.O_RDONLY
if attrFlags.Size {
// Only open in write mode if needed to truncate.
mode = os.O_WRONLY
}
f, err := safefile.OpenFileNoFollow(file, mode, 0)
if err != nil {
return err
}
defer f.Close()
if attrFlags.Size {
if err := f.Truncate(int64(attrs.Size)); err != nil {
return err
}
}
if attrFlags.Acmodtime {
if err := chtimes(f, time.Unix(int64(attrs.Atime), 0), time.Unix(int64(attrs.Mtime), 0)); err != nil {
return err
}
}
if attrFlags.Permissions {
// Prevent setuid/setgid/sticky bits from being set.
mode := attrs.FileMode() & os.ModePerm
if err := f.Chmod(mode); err != nil {
return err
}
}
if attrFlags.UidGid {
if err := f.Chown(int(attrs.UID), int(attrs.GID)); err != nil {
return err
}
}
return nil
}