Skip to content

feat: block file transfers for security #13501

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Jun 10, 2024
Merged
Show file tree
Hide file tree
Changes from 10 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
4 changes: 4 additions & 0 deletions agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ type Options struct {
ModifiedProcesses chan []*agentproc.Process
// ProcessManagementTick is used for testing process priority management.
ProcessManagementTick <-chan time.Time
BlockFileTransfer bool
}

type Client interface {
Expand Down Expand Up @@ -184,6 +185,7 @@ func New(options Options) Agent {
modifiedProcs: options.ModifiedProcesses,
processManagementTick: options.ProcessManagementTick,
logSender: agentsdk.NewLogSender(options.Logger),
blockFileTransfer: options.BlockFileTransfer,

prometheusRegistry: prometheusRegistry,
metrics: newAgentMetrics(prometheusRegistry),
Expand Down Expand Up @@ -239,6 +241,7 @@ type agent struct {
sessionToken atomic.Pointer[string]
sshServer *agentssh.Server
sshMaxTimeout time.Duration
blockFileTransfer bool

lifecycleUpdate chan struct{}
lifecycleReported chan codersdk.WorkspaceAgentLifecycle
Expand Down Expand Up @@ -277,6 +280,7 @@ func (a *agent) init() {
AnnouncementBanners: func() *[]codersdk.BannerConfig { return a.announcementBanners.Load() },
UpdateEnv: a.updateCommandEnv,
WorkingDirectory: func() string { return a.manifest.Load().Directory },
BlockFileTransfer: a.blockFileTransfer,
})
if err != nil {
panic(err)
Expand Down
90 changes: 90 additions & 0 deletions agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -970,6 +970,96 @@ func TestAgent_SCP(t *testing.T) {
require.NoError(t, err)
}

func TestAgent_FileTransferBlocked(t *testing.T) {
t.Parallel()

assertFileTransferBlocked := func(t *testing.T, errorMessage string) {
// NOTE: Checking content of the error message is flaky. It can catch different responses.
isErr := strings.Contains(errorMessage, agentssh.BlockedFileTransferErrorMessage) ||
strings.Contains(errorMessage, "EOF") ||
strings.Contains(errorMessage, "Process exited with status 2")
require.True(t, isErr, fmt.Sprintf("Message: "+errorMessage))
}

t.Run("SFTP", func(t *testing.T) {
t.Parallel()

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()

//nolint:dogsled
conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) {
o.BlockFileTransfer = true
})
sshClient, err := conn.SSHClient(ctx)
require.NoError(t, err)
defer sshClient.Close()
_, err = sftp.NewClient(sshClient)
require.Error(t, err)
assertFileTransferBlocked(t, err.Error())
})

t.Run("SCP with go-scp package", func(t *testing.T) {
t.Parallel()

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()

//nolint:dogsled
conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) {
o.BlockFileTransfer = true
})
sshClient, err := conn.SSHClient(ctx)
require.NoError(t, err)
defer sshClient.Close()
scpClient, err := scp.NewClientBySSH(sshClient)
require.NoError(t, err)
defer scpClient.Close()
tempFile := filepath.Join(t.TempDir(), "scp")
err = scpClient.CopyFile(context.Background(), strings.NewReader("hello world"), tempFile, "0755")
require.Error(t, err)
assertFileTransferBlocked(t, err.Error())
})

t.Run("Forbidden commands", func(t *testing.T) {
t.Parallel()

commands := []string{"nc", "rsync", "scp", "sftp"}
for _, c := range commands {
c := c
t.Run(c, func(t *testing.T) {
t.Parallel()

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()

//nolint:dogsled
conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) {
o.BlockFileTransfer = true
})
sshClient, err := conn.SSHClient(ctx)
require.NoError(t, err)
defer sshClient.Close()

session, err := sshClient.NewSession()
require.NoError(t, err)
defer session.Close()

stdout, err := session.StdoutPipe()
require.NoError(t, err)

err = session.Start(c)
require.NoError(t, err)
defer session.Close()

msg, err := io.ReadAll(stdout)
require.NoError(t, err)
assertFileTransferBlocked(t, string(msg))
})
}
})
}

func TestAgent_EnvironmentVariables(t *testing.T) {
t.Parallel()
key := "EXAMPLE"
Expand Down
48 changes: 48 additions & 0 deletions agent/agentssh/agentssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ const (
// MagicProcessCmdlineJetBrains is a string in a process's command line that
// uniquely identifies it as JetBrains software.
MagicProcessCmdlineJetBrains = "idea.vendor.name=JetBrains"

// BlockedFileTransferErrorCode indicates that SSH server restricted the raw command from performing
// the file transfer.
BlockedFileTransferErrorCode = 2
BlockedFileTransferErrorMessage = "File transfer has been disabled."
)

// Config sets configuration parameters for the agent SSH server.
Expand All @@ -74,6 +79,8 @@ type Config struct {
// X11SocketDir is the directory where X11 sockets are created. Default is
// /tmp/.X11-unix.
X11SocketDir string
// BlockFileTransfer restricts use of file transfer applications.
BlockFileTransfer bool
}

type Server struct {
Expand Down Expand Up @@ -272,6 +279,18 @@ func (s *Server) sessionHandler(session ssh.Session) {
extraEnv = append(extraEnv, fmt.Sprintf("DISPLAY=:%d.0", x11.ScreenNumber))
}

if s.fileTransferBlocked(session) {
s.logger.Warn(ctx, "file transfer blocked", slog.F("session_subsystem", session.Subsystem()), slog.F("raw_command", session.RawCommand()))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

out of scope: we may want to eventually have a way to surface this to admins as they may be interested in this sort of thing.

Copy link
Member Author

@mtojek mtojek Jun 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a new metric? maybe consider this as a follow-up option. I don't know if an admin wants to review notifications from users accidentally trying to use scp.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we run the risk here of logging a potentially large amount of text here?
Should we try and capture the user identity associated with this attempt, too?

Aside: this feels like a good use-case for an audit-log.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as #13501 (comment)?

Yeah, the problem here is that it is logged on the agent side in /tmp/coder.blahblah/coder-agent-<some-file>.log, and it is not raised to admins. We may want to think about some metrics to notify them.


if session.Subsystem() == "" { // sftp does not expect error, otherwise it fails with "package too long"
// Response format: <status_code><message body>\n
errorMessage := fmt.Sprintf("\x02%s\n", BlockedFileTransferErrorMessage)
_, _ = session.Write([]byte(errorMessage))
}
_ = session.Exit(BlockedFileTransferErrorCode)
return
}

switch ss := session.Subsystem(); ss {
case "":
case "sftp":
Expand Down Expand Up @@ -322,6 +341,35 @@ func (s *Server) sessionHandler(session ssh.Session) {
_ = session.Exit(0)
}

// fileTransferBlocked method checks if the file transfer commands should be blocked.
// Warning: consider this mechanism as "Do not trespass" sign. If a user needs a more sophisticated
// and battle-proof solution, consider the full endpoint security.
func (s *Server) fileTransferBlocked(session ssh.Session) bool {
if !s.config.BlockFileTransfer {
return false // file transfers are permitted
}
// File transfers are restricted.

if session.Subsystem() == "sftp" {
return true
}

cmd := session.Command()
if len(cmd) == 0 {
return false // no command?
}

c := cmd[0]
c = filepath.Base(c) // in case the binary is absolute path, /usr/sbin/scp

switch c {
case "nc", "rsync", "scp", "sftp":
return true // forbidden command
default:
return false
}
}

func (s *Server) sessionStart(logger slog.Logger, session ssh.Session, extraEnv []string) (retErr error) {
ctx := session.Context()
env := append(session.Environ(), extraEnv...)
Expand Down
10 changes: 10 additions & 0 deletions cli/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ func (r *RootCmd) workspaceAgent() *serpent.Command {
slogHumanPath string
slogJSONPath string
slogStackdriverPath string
blockFileTransfer bool
)
cmd := &serpent.Command{
Use: "agent",
Expand Down Expand Up @@ -314,6 +315,8 @@ func (r *RootCmd) workspaceAgent() *serpent.Command {
// Intentionally set this to nil. It's mainly used
// for testing.
ModifiedProcesses: nil,

BlockFileTransfer: blockFileTransfer,
})

promHandler := agent.PrometheusMetricsHandler(prometheusRegistry, logger)
Expand Down Expand Up @@ -417,6 +420,13 @@ func (r *RootCmd) workspaceAgent() *serpent.Command {
Default: "",
Value: serpent.StringOf(&slogStackdriverPath),
},
{
Flag: "block-file-transfer",
Default: "false",
Env: "CODER_BLOCK_FILE_TRANSFER",
Description: "Block file transfer using known applications: nc, rsync, scp, sftp.",
Value: serpent.BoolOf(&blockFileTransfer),
},
}

return cmd
Expand Down
3 changes: 3 additions & 0 deletions cli/testdata/coder_agent_--help.golden
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ OPTIONS:
--auth string, $CODER_AGENT_AUTH (default: token)
Specify the authentication type to use for the agent.

--block-file-transfer bool, $CODER_BLOCK_FILE_TRANSFER (default: false)
Block file transfer using known applications: nc, rsync, scp, sftp.

--debug-address string, $CODER_AGENT_DEBUG_ADDRESS (default: 127.0.0.1:2113)
The bind address to serve a debug HTTP server.

Expand Down
Loading