Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions api/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -421,3 +421,11 @@ const (
// used for connection upgrades.
WebAPIConnUpgradeConnectionType = "Upgrade"
)

const (
// InitiateFileTransfer is used when creating a new file transfer request
InitiateFileTransfer string = "file-transfer@goteleport.com"
// FileTransferDecision is a request that will approve or deny an active file transfer.
// Multiple decisions can be sent for the same request if the policy requires it.
FileTransferDecision string = "file-transfer-decision@goteleport.com"
)
30 changes: 30 additions & 0 deletions api/observability/tracing/ssh/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
oteltrace "go.opentelemetry.io/otel/trace"
"golang.org/x/crypto/ssh"

"github.com/gravitational/teleport/api/constants"
"github.com/gravitational/teleport/api/observability/tracing"
)

Expand Down Expand Up @@ -342,3 +343,32 @@ func (s *Session) CombinedOutput(ctx context.Context, cmd string) ([]byte, error
output, err := s.Session.CombinedOutput(cmd)
return output, trace.Wrap(err)
}

// sendFileTransferDecision will send a "file-transfer-decision@goteleport.com" ssh request
func (s *Session) sendFileTransferDecision(ctx context.Context, requestID string, approved bool) error {
req := &FileTransferDecisionReq{
RequestID: requestID,
Approved: approved,
}
_, err := s.SendRequest(ctx, constants.FileTransferDecision, true, ssh.Marshal(req))
return trace.Wrap(err)
}

// ApproveFileTransferRequest sends a "file-transfer-decision@goteleport.com" ssh request
// The ssh request will have the request ID and Approved: true
func (s *Session) ApproveFileTransferRequest(ctx context.Context, requestID string) error {
return trace.Wrap(s.sendFileTransferDecision(ctx, requestID, true))
}

// DenyFileTransferRequest sends a "file-transfer-decision@goteleport.com" ssh request
// The ssh request will have the request ID and Approved: false
func (s *Session) DenyFileTransferRequest(ctx context.Context, requestID string) error {
return trace.Wrap(s.sendFileTransferDecision(ctx, requestID, false))
}

// RequestFileTransfer sends a "file-transfer-request@goteleport.com" ssh request that will create a new file transfer request
// and notify the parties in an ssh session
func (s *Session) RequestFileTransfer(ctx context.Context, req FileTransferReq) error {
_, err := s.SendRequest(ctx, constants.InitiateFileTransfer, true, ssh.Marshal(req))
return trace.Wrap(err)
}
20 changes: 20 additions & 0 deletions api/observability/tracing/ssh/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,26 @@ type EnvsReq struct {
EnvsJSON []byte `json:"envs"`
}

// FileTransferReq contains parameters used to create a file transfer
// request to be stored in the SSH server
type FileTransferReq struct {
// Download is true if the file transfer requests a download, false if upload
Download bool
// Location is the location of the file to be downloaded, or directory to upload a file
Location string
// Filename is the name of the file to be uploaded
Filename string
}

// FileTransferDecisionReq contains parameters used to approve or deny an active
// file transfer request on the SSH server
type FileTransferDecisionReq struct {
// RequestID is the ID of the file transfer request being responded to
RequestID string
// Approved is true if approved, false if denied.
Approved bool
}

// ContextFromRequest extracts any tracing data provided via an Envelope
// in the ssh.Request payload. If the payload contains an Envelope, then
// the context returned will have tracing data populated from the remote
Expand Down
10 changes: 2 additions & 8 deletions api/proto/teleport/legacy/types/events/events.proto
Original file line number Diff line number Diff line change
Expand Up @@ -629,19 +629,13 @@ message FileTransferRequestEvent {
// Location is the location of the file to be downloaded, or the directory of the upload
string Location = 6 [(gogoproto.jsontag) = "location"];

// Direction is "upload" or "download" for the requested file transfer
FileTransferDirection Direction = 7 [(gogoproto.jsontag) = "direction"];
// Download is true if the requested file transfer is a download, false if an upload
bool Download = 7 [(gogoproto.jsontag) = "download"];

// Filename is the name of the file to be uploaded to the Location. Only present in uploads.
string Filename = 8 [(gogoproto.jsontag) = "filename"];
}

// FileTransferDirection is the direction for a requested file transfer
enum FileTransferDirection {
DOWNLOAD = 0;
UPLOAD = 1;
}

// Resize means that some user resized PTY on the client
message Resize {
// Metadata is a common event metadata
Expand Down
1,419 changes: 698 additions & 721 deletions api/types/events/events.pb.go

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions lib/defaults/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,13 @@ const (
// WebsocketResize is receiving a resize request.
WebsocketResize = "w"

// WebsocketFileTransferRequest is received when a new file transfer has been requested
WebsocketFileTransferRequest = "f"

// WebsocketFileTransferDecision is received when a response (approve/deny) has been
// made for an existing file transfer request
WebsocketFileTransferDecision = "t"

// WebsocketWebauthnChallenge is sending a webauthn challenge.
WebsocketWebauthnChallenge = "n"

Expand Down
22 changes: 22 additions & 0 deletions lib/session/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,28 @@ type Session struct {
Moderated bool `json:"moderated"`
}

// FileTransferRequestParams contain parameters for requesting a file transfer
type FileTransferRequestParams struct {
// Download is true if the request is a download, false if it is an upload
Download bool `json:"direction"`
// Location is location of file to download, or where to put an upload
Location string `json:"location"`
// Filename is the name of the file to be uploaded
Filename string `json:"filename"`
// Requester is the authenticated Teleport user who requested the file transfer
Requester string `json:"requester"`
// Approvers is a list of teleport users who have approved the file transfer request
Approvers []Party `json:"approvers"`
}

// FileTransferDecisionParams contains parameters for approving or denying a file transfer request
type FileTransferDecisionParams struct {
// RequestID is the ID of the request being responded to
RequestID string `json:"requestId"`
// Approved is true if the response approves a file transfer request
Approved bool `json:"approved"`
}

// Participants returns the usernames of the current session participants.
func (s *Session) Participants() []string {
participants := make([]string, 0, len(s.Parties))
Expand Down
6 changes: 6 additions & 0 deletions lib/srv/regular/sshserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -1610,6 +1610,8 @@ func (s *Server) dispatch(ctx context.Context, ch ssh.Channel, req *ssh.Request,
case teleport.ForceTerminateRequest:
return s.termHandlers.HandleForceTerminate(ch, req, serverContext)
case sshutils.EnvRequest, tracessh.EnvsRequest:
case constants.FileTransferDecision:
return s.termHandlers.HandleFileTransferDecision(ctx, ch, req, serverContext)
// We ignore all SSH setenv requests for join-only principals.
// SSH will send them anyway but it seems fine to silently drop them.
case sshutils.SubsystemRequest:
Expand Down Expand Up @@ -1651,6 +1653,10 @@ func (s *Server) dispatch(ctx context.Context, ch ssh.Channel, req *ssh.Request,
return trace.Wrap(err)
}
return s.termHandlers.HandleShell(ctx, ch, req, serverContext)
case constants.InitiateFileTransfer:
return s.termHandlers.HandleFileTransferRequest(ctx, ch, req, serverContext)
case constants.FileTransferDecision:
return s.termHandlers.HandleFileTransferDecision(ctx, ch, req, serverContext)
case sshutils.WindowChangeRequest:
return s.termHandlers.HandleWinChange(ctx, ch, req, serverContext)
case teleport.ForceTerminateRequest:
Expand Down
189 changes: 181 additions & 8 deletions lib/srv/sess.go
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,11 @@ func (s *SessionRegistry) isApprovedFileTransfer(scx *ServerContext) (bool, erro
s.sessionsMux.Lock()
defer s.sessionsMux.Unlock()

// get the requested location from env vars
location, _ := scx.GetEnv(sftp.FileTransferDstPath)
if location == "" {
return false, nil
}
// if a sessID and requestID environment variables were not set, return not approved and no error.
// This means the file transfer came from a non-moderated session. sessionID will be passed after a
// moderated session approval process has completed.
Expand All @@ -375,19 +380,80 @@ func (s *SessionRegistry) isApprovedFileTransfer(scx *ServerContext) (bool, erro
return false, trace.NotFound("File transfer request not found")
}

if req.location != location {
return false, trace.AccessDenied("requested destination path does not match the current request")
}

if req.requester != scx.Identity.TeleportUser {
return false, trace.AccessDenied("Teleport user does not match original requester")
}

var incomingShellCmd string
if scx.sshRequest != nil {
incomingShellCmd = string(scx.sshRequest.Payload)
return sess.checkIfFileTransferApproved(req)
}

// FileTransferRequestEvent is an event used to Notify party members during File Transfer Request approval process
type FileTransferRequestEvent string

const (
// FileTransferUpdate is used when a file transfer request is created or updated.
// An update will happen if a file transfer request was approved but the policy still isn't fulfilled
FileTransferUpdate FileTransferRequestEvent = "file_transfer_request"
// FileTransferApproved is used when a file transfer request has received an approval decision
// and the policy is fulfilled. This lets the client know that the file transfer is ready to download/upload
// and be removed from any pending state.
FileTransferApproved FileTransferRequestEvent = "file_transfer_request_approve"
// FileTransferDenied is used when a file transfer request is denied. This lets the client know to remove
// this file transfer from any pending state.
FileTransferDenied FileTransferRequestEvent = "file_transfer_request_deny"
)

// NotifyFileTransferRequest is called to notify all members of a party that a file transfer request has been created/approved/denied.
// The notification is a global ssh request and requires the client to update its UI state accordingly.
func (s *SessionRegistry) NotifyFileTransferRequest(req *fileTransferRequest, res FileTransferRequestEvent, scx *ServerContext) error {
session := scx.getSession()
if session == nil {
s.log.Debugf("Unable to notify %s, no session found in context.", res)
return trace.NotFound("no session found in context")
}
if incomingShellCmd != req.shellCmd {
return false, trace.AccessDenied("Incoming request does not match the approved request")
sid := session.id

fileTransferEvent := &apievents.FileTransferRequestEvent{
Metadata: apievents.Metadata{
Type: string(res),
ClusterName: scx.ClusterName,
},
SessionMetadata: apievents.SessionMetadata{
SessionID: string(sid),
},
RequestID: req.id,
Requester: req.requester,
Location: req.location,
Filename: req.filename,
Download: req.download,
Approvers: make([]string, 0),
}

return sess.checkIfFileTransferApproved(req)
for _, approver := range req.approvers {
fileTransferEvent.Approvers = append(fileTransferEvent.Approvers, approver.user)
}

eventPayload, err := json.Marshal(fileTransferEvent)
if err != nil {
s.log.Warnf("Unable to marshal %s event: %v.", res, err)
return trace.Wrap(err)
}

for _, p := range session.parties {
// Send the message as a global request.
_, _, err = p.sconn.SendRequest(teleport.SessionEvent, false, eventPayload)
if err != nil {
s.log.Warnf("Unable to send %s event to %v: %v.", res, p.sconn.RemoteAddr(), err)
continue
}
s.log.Debugf("Sent %s event to %v.", res, p.sconn.RemoteAddr())
}

return nil
}

// NotifyWinChange is called to notify all members in the party that the PTY
Expand Down Expand Up @@ -606,6 +672,7 @@ func newSession(ctx context.Context, id rsession.ID, r *SessionRegistry, scx *Se
id: id,
registry: r,
parties: make(map[rsession.ID]*party),
fileTransferRequests: make(map[string]*fileTransferRequest),
participants: make(map[rsession.ID]*party),
login: scx.Identity.Login,
stopC: make(chan struct{}),
Expand Down Expand Up @@ -1463,11 +1530,17 @@ func (s *session) checkPresence() error {
return nil
}

// fileTransferRequest is a request to upload or download a file from the node.
type fileTransferRequest struct {
id string
// requester is the Teleport User that requested the file transfer
requester string
// shellCmd is the requested scp command to run
shellCmd string
// download is true if the request is a download, false if its an upload
download bool
// filename the name of the file to upload.
filename string
// location of the requested download or where a file will be uploaded
location string
// approvers is a list of participants of moderator or peer type that have approved the request
approvers map[string]*party
}
Expand Down Expand Up @@ -1495,6 +1568,106 @@ func (s *session) checkIfFileTransferApproved(req *fileTransferRequest) (bool, e
return isApproved, nil
}

// newFileTransferRequest takes FileTransferParams and creates a new fileTransferRequest struct
func (s *session) newFileTransferRequest(params *rsession.FileTransferRequestParams) *fileTransferRequest {
return &fileTransferRequest{
id: uuid.New().String(),
requester: params.Requester,
location: params.Location,
filename: params.Filename,
download: params.Download,
approvers: make(map[string]*party),
}
}

// addFileTransferRequest will create a new file transfer request and add it to the current session's fileTransferRequests map
// and broadcast the appropriate string to the session.
func (s *session) addFileTransferRequest(params *rsession.FileTransferRequestParams, scx *ServerContext) *fileTransferRequest {
s.mu.Lock()
defer s.mu.Unlock()

req := s.newFileTransferRequest(params)
s.fileTransferRequests[req.id] = req
if params.Download {
s.BroadcastMessage("User %s would like to download: %s", params.Requester, params.Location)
} else {
s.BroadcastMessage("User %s would like to upload %s to: %s", params.Requester, params.Filename, params.Location)
}

s.registry.NotifyFileTransferRequest(req, FileTransferUpdate, scx)
return req
}

// approveFileTransferRequest will add the approver to the approvers map of a file transfer request and notify the members
// of the session if the updated approvers map would fulfill the moderated policy.
func (s *session) approveFileTransferRequest(params *rsession.FileTransferDecisionParams, scx *ServerContext) (*fileTransferRequest, error) {
s.mu.Lock()
defer s.mu.Unlock()

fileTransferReq := s.fileTransferRequests[params.RequestID]
if fileTransferReq == nil {
return nil, trace.NotFound("File Transfer Request %s not found", params.RequestID)
}

var approver *party
for _, p := range s.parties {
if p.ctx.ID() == scx.ID() {
approver = p
}
}
if approver == nil {
return nil, trace.AccessDenied("cannot approve file transfer requests if not in the current moderated session")
}

fileTransferReq.approvers[approver.user] = approver
s.BroadcastMessage("%s approved file transfer request %s", scx.Identity.TeleportUser, fileTransferReq.id)

// check if policy is fulfilled
approved, err := s.checkIfFileTransferApproved(fileTransferReq)
if err != nil {
return nil, trace.Wrap(err)
}

var eventType FileTransferRequestEvent
if approved {
eventType = FileTransferApproved
} else {
eventType = FileTransferUpdate
}

s.registry.NotifyFileTransferRequest(fileTransferReq, eventType, scx)

return fileTransferReq, nil
}

// denyFileTransferRequest will deny a file transfer request and remove it from the current session's file transfer requests map.
// A file transfer request does not persist after deny, so there is no "denied" state. Deny in this case is synonymous with delete
// with the addition of checking for a valid denier.
func (s *session) denyFileTransferRequest(params *rsession.FileTransferDecisionParams, scx *ServerContext) (*fileTransferRequest, error) {
s.mu.Lock()
defer s.mu.Unlock()
fileTransferReq := s.fileTransferRequests[params.RequestID]
if fileTransferReq == nil {
return nil, trace.NotFound("file transfer request %s not found", params.RequestID)
}
var denier *party
for _, p := range s.parties {
if p.ctx.ID() == scx.ID() {
denier = p
}
}
if denier == nil {
return nil, trace.AccessDenied("cannot deny file transfer requests if not in the current moderated session")
}

delete(s.fileTransferRequests, fileTransferReq.id)

s.BroadcastMessage("%s denied file transfer request %s", scx.Identity.TeleportUser, fileTransferReq.id)
s.registry.NotifyFileTransferRequest(fileTransferReq, FileTransferDenied, scx)

return fileTransferReq, nil
}

func (s *session) checkIfStart() (bool, auth.PolicyOptions, error) {
var participants []auth.SessionAccessContext

Expand Down
Loading