Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
eabeeff
Add file transfer handlers to web terminal
avatus Apr 13, 2023
5bec25a
remove unused keys
avatus Apr 13, 2023
3198c3f
Make fileTransferC buffered
avatus Apr 16, 2023
55a9188
Review feedback
avatus Apr 16, 2023
8521b0d
Fix typo
avatus Apr 16, 2023
78c4761
Merge branch 'michaelmyers/ssh_server_file_transfer_handlers' into mi…
avatus Apr 16, 2023
1b730da
Merge branch 'michaelmyers/ssh_server_file_transfer_handlers' into mi…
avatus Apr 17, 2023
5925b3b
Remove comment
avatus Apr 17, 2023
076b78d
Merge branch 'michaelmyers/ssh_server_file_transfer_handlers' into mi…
avatus Apr 17, 2023
82ed78d
Simplify session param
avatus Apr 17, 2023
ef178bd
Merge branch 'michaelmyers/ssh_server_file_transfer_handlers' into mi…
avatus Apr 17, 2023
6efdc11
Simplify file transfer param callsite
avatus Apr 17, 2023
bd97802
Merge branch 'michaelmyers/ssh_server_file_transfer_handlers' into mi…
avatus Apr 17, 2023
e63dbe6
Use api constants
avatus Apr 17, 2023
f69c8e7
Split FileTransferRequestResponse methods
avatus Apr 17, 2023
4299567
Merge base
avatus Apr 19, 2023
6e80001
Change name to FileTransferDecision
avatus Apr 19, 2023
61dcc27
Merge base
avatus Apr 25, 2023
39a0985
Update ssh request name
avatus Apr 25, 2023
bf50fb1
Merge branch 'michaelmyers/ssh_server_file_transfer_handlers' into mi…
avatus Apr 25, 2023
a927a6a
use FileTransferReq
avatus Apr 25, 2023
d429e87
Rename struct
avatus Apr 26, 2023
2bb6bf1
Add file transfer test
avatus Apr 26, 2023
df87268
Add FileTransferDecision to test
avatus Apr 26, 2023
2ae127e
Add dstPath to isApprovedFileTransfer
avatus Apr 28, 2023
585cfb0
Remove debug comment
avatus Apr 28, 2023
a994465
Merge branch 'michaelmyers/ssh_server_file_transfer_handlers' into mi…
avatus May 3, 2023
a20ac31
Rename query params
avatus May 3, 2023
62082dd
[web] Moderated Session file transfers (#24583)
avatus May 3, 2023
c8d29b6
Prefix file transfer env vars with TELEPORT_
avatus May 3, 2023
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
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)
}
7 changes: 7 additions & 0 deletions lib/defaults/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,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
10 changes: 10 additions & 0 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,6 +380,10 @@ 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")
}
Expand Down Expand Up @@ -663,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
16 changes: 16 additions & 0 deletions lib/srv/sess_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ func TestIsApprovedFileTransfer(t *testing.T) {
expectedError string
req *fileTransferRequest
reqID string
location string
}{

{
Expand All @@ -167,14 +168,28 @@ func TestIsApprovedFileTransfer(t *testing.T) {
approvers: make(map[string]*party),
},
},
{
name: "current request location does not match original location",
expectedResult: false,
expectedError: "requested destination path does not match the current request",
reqID: "123",
location: "~/Downloads",
req: &fileTransferRequest{
requester: "michael",
approvers: make(map[string]*party),
location: "~/badlocation",
},
},
{
name: "approved request",
expectedResult: true,
expectedError: "",
reqID: "123",
location: "~/Downloads",
req: &fileTransferRequest{
requester: "teleportUser",
approvers: approvers,
location: "~/Downloads",
},
},
}
Expand All @@ -193,6 +208,7 @@ func TestIsApprovedFileTransfer(t *testing.T) {
scx := newTestServerContext(t, reg.Srv, accessRoleSet)
scx.SetEnv(string(sftp.ModeratedSessionID), sess.ID())
scx.SetEnv(string(sftp.FileTransferRequestID), tt.reqID)
scx.SetEnv(sftp.FileTransferDstPath, tt.location)
result, err := reg.isApprovedFileTransfer(scx)
if err != nil {
require.Equal(t, tt.expectedError, err.Error())
Expand Down
7 changes: 5 additions & 2 deletions lib/sshutils/sftp/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,18 @@ import (
type contextKey string

const (
// FileTransferDstPath is the dstPath (location) for the requested file transfer. This would be equal
// to the file to be downloaded, or location for a file to be uploaded.
FileTransferDstPath string = "TELEPORT_FILE_TRANSFER_DST_PATH"
// FileTransferRequestID is an optional parameter id of an file transfer request that has gone through
// an approval process during a moderated session to allow a file transfer scp command to be executed
// used as a value in the file transfer context and env var for exec session
FileTransferRequestID contextKey = "FILE_TRANSFER_REQUEST_ID"
FileTransferRequestID contextKey = "TELEPORT_FILE_TRANSFER_REQUEST_ID"

// ModeratedSessionID is an optional parameter sent during SCP requests to specify which moderated session
// to check for valid FileTransferRequests
// used as a value in the file transfer context and env var for exec session
ModeratedSessionID contextKey = "MODERATED_SESSION_ID"
ModeratedSessionID contextKey = "TELEPORT_MODERATED_SESSION_ID"
)

var errDirsNotSupported = trace.BadParameter("directories are not supported when transferring files over HTTP")
Expand Down
2 changes: 2 additions & 0 deletions lib/sshutils/sftp/sftp.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,8 @@ func (c *Config) TransferFiles(ctx context.Context, sshClient *ssh.Client) error
if fileTransferRequestID, ok := ctx.Value(FileTransferRequestID).(string); ok {
s.Setenv(string(FileTransferRequestID), fileTransferRequestID)
}
// set dstPath in env var to check against file transfer request location
s.Setenv(FileTransferDstPath, c.dstPath)

pe, err := s.StderrPipe()
if err != nil {
Expand Down
123 changes: 123 additions & 0 deletions lib/web/apiserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1129,6 +1129,129 @@ func TestResolveServerHostPort(t *testing.T) {
}
}

func isFileTransferRequest(e *Envelope) bool {
if e.GetType() != defaults.WebsocketAudit {
return false
}
var ef events.EventFields
if err := json.Unmarshal([]byte(e.GetPayload()), &ef); err != nil {
return false
}
return ef.GetType() == string(srv.FileTransferUpdate)
}

func isFileTransferDecision(e *Envelope) bool {
if e.GetType() != defaults.WebsocketAudit {
return false
}
var ef events.EventFields
if err := json.Unmarshal([]byte(e.GetPayload()), &ef); err != nil {
return false
}
return ef.GetType() == string(srv.FileTransferApproved)
}

func getRequestId(e *Envelope) (string, error) {
var ef events.EventFields
if err := json.Unmarshal([]byte(e.GetPayload()), &ef); err != nil {
return "", err
}
return ef.GetString("requestID"), nil
}

func TestFileTransferEvents(t *testing.T) {
t.Parallel()
s := newWebSuiteWithConfig(t, webSuiteConfig{disableDiskBasedRecording: true})

errs := make(chan error, 2)
readLoop := func(ctx context.Context, ws *websocket.Conn, ch chan<- *Envelope) {
for {
select {
case <-ctx.Done():
return
default:
}

typ, b, err := ws.ReadMessage()
if err != nil {
errs <- err
return
}
if typ != websocket.BinaryMessage {
errs <- trace.BadParameter("expected binary message, got %v", typ)
return
}
var envelope Envelope
if err := proto.Unmarshal(b, &envelope); err != nil {
errs <- trace.Wrap(err)
return
}
ch <- &envelope
}
}

// Create a new user "foo", open a terminal to a new session
pack := s.authPack(t, "foo")
ws, _, err := s.makeTerminal(t, pack)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, ws.Close()) })

ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
wsMessages := make(chan *Envelope)
go readLoop(ctx, ws, wsMessages)

// Create file transfer event
data, err := json.Marshal(events.EventFields{
"download": true,
"location": "~/myfile.txt",
})

require.NoError(t, err)
envelope := &Envelope{
Version: defaults.WebsocketVersion,
Type: defaults.WebsocketFileTransferRequest,
Payload: string(data),
}
envelopeBytes, err := proto.Marshal(envelope)
require.NoError(t, err)
err = ws.WriteMessage(websocket.BinaryMessage, envelopeBytes)
require.NoError(t, err)

done := time.After(5 * time.Second)
for {
select {
case <-done:
require.FailNow(t, "expected to receive a file transfer event")
case err := <-errs:
require.NoError(t, err)
case e := <-wsMessages:
if isFileTransferRequest(e) {
requestId, err := getRequestId(e)
require.NoError(t, err)
data, err := json.Marshal(events.EventFields{
"requestId": requestId,
"approved": true,
})
require.NoError(t, err)
envelope := &Envelope{
Version: defaults.WebsocketVersion,
Type: defaults.WebsocketFileTransferDecision,
Payload: string(data),
}
envelopeBytes, err := proto.Marshal(envelope)
require.NoError(t, err)
err = ws.WriteMessage(websocket.BinaryMessage, envelopeBytes)
require.NoError(t, err)
}

if isFileTransferDecision(e) {
return
}
}
}
}

func TestNewTerminalHandler(t *testing.T) {
ctx := context.Background()

Expand Down
6 changes: 3 additions & 3 deletions lib/web/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,13 @@ func (h *Handler) transferFile(w http.ResponseWriter, r *http.Request, p httprou
filename: query.Get("filename"),
namespace: defaults.Namespace,
webauthn: query.Get("webauthn"),
fileTransferRequestID: query.Get("file_transfer_request_id"),
moderatedSessionID: query.Get("moderated_session_id"),
fileTransferRequestID: query.Get("fileTransferRequestId"),
moderatedSessionID: query.Get("moderatedSessionId"),
}

// Send an error if only one of these params has been sent. Both should exist or not exist together
if (req.fileTransferRequestID != "") != (req.moderatedSessionID != "") {
return nil, trace.BadParameter("file_transfer_request_id and moderated_session_id must both be included in the same request.")
return nil, trace.BadParameter("fileTransferRequestId and moderatedSessionId must both be included in the same request.")
}

clt, err := sctx.GetUserClient(r.Context(), site)
Expand Down
Loading