Skip to content

Commit e96652e

Browse files
authored
feat: block file transfers for security (#13501)
1 parent 8326a3a commit e96652e

File tree

5 files changed

+164
-0
lines changed

5 files changed

+164
-0
lines changed

agent/agent.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ type Options struct {
9191
ModifiedProcesses chan []*agentproc.Process
9292
// ProcessManagementTick is used for testing process priority management.
9393
ProcessManagementTick <-chan time.Time
94+
BlockFileTransfer bool
9495
}
9596

9697
type Client interface {
@@ -184,6 +185,7 @@ func New(options Options) Agent {
184185
modifiedProcs: options.ModifiedProcesses,
185186
processManagementTick: options.ProcessManagementTick,
186187
logSender: agentsdk.NewLogSender(options.Logger),
188+
blockFileTransfer: options.BlockFileTransfer,
187189

188190
prometheusRegistry: prometheusRegistry,
189191
metrics: newAgentMetrics(prometheusRegistry),
@@ -239,6 +241,7 @@ type agent struct {
239241
sessionToken atomic.Pointer[string]
240242
sshServer *agentssh.Server
241243
sshMaxTimeout time.Duration
244+
blockFileTransfer bool
242245

243246
lifecycleUpdate chan struct{}
244247
lifecycleReported chan codersdk.WorkspaceAgentLifecycle
@@ -277,6 +280,7 @@ func (a *agent) init() {
277280
AnnouncementBanners: func() *[]codersdk.BannerConfig { return a.announcementBanners.Load() },
278281
UpdateEnv: a.updateCommandEnv,
279282
WorkingDirectory: func() string { return a.manifest.Load().Directory },
283+
BlockFileTransfer: a.blockFileTransfer,
280284
})
281285
if err != nil {
282286
panic(err)

agent/agent_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -970,6 +970,99 @@ func TestAgent_SCP(t *testing.T) {
970970
require.NoError(t, err)
971971
}
972972

973+
func TestAgent_FileTransferBlocked(t *testing.T) {
974+
t.Parallel()
975+
976+
assertFileTransferBlocked := func(t *testing.T, errorMessage string) {
977+
// NOTE: Checking content of the error message is flaky. Most likely there is a race condition, which results
978+
// in stopping the client in different phases, and returning different errors:
979+
// - client read the full error message: File transfer has been disabled.
980+
// - client's stream was terminated before reading the error message: EOF
981+
// - client just read the error code (Windows): Process exited with status 65
982+
isErr := strings.Contains(errorMessage, agentssh.BlockedFileTransferErrorMessage) ||
983+
strings.Contains(errorMessage, "EOF") ||
984+
strings.Contains(errorMessage, "Process exited with status 65")
985+
require.True(t, isErr, fmt.Sprintf("Message: "+errorMessage))
986+
}
987+
988+
t.Run("SFTP", func(t *testing.T) {
989+
t.Parallel()
990+
991+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
992+
defer cancel()
993+
994+
//nolint:dogsled
995+
conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) {
996+
o.BlockFileTransfer = true
997+
})
998+
sshClient, err := conn.SSHClient(ctx)
999+
require.NoError(t, err)
1000+
defer sshClient.Close()
1001+
_, err = sftp.NewClient(sshClient)
1002+
require.Error(t, err)
1003+
assertFileTransferBlocked(t, err.Error())
1004+
})
1005+
1006+
t.Run("SCP with go-scp package", func(t *testing.T) {
1007+
t.Parallel()
1008+
1009+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
1010+
defer cancel()
1011+
1012+
//nolint:dogsled
1013+
conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) {
1014+
o.BlockFileTransfer = true
1015+
})
1016+
sshClient, err := conn.SSHClient(ctx)
1017+
require.NoError(t, err)
1018+
defer sshClient.Close()
1019+
scpClient, err := scp.NewClientBySSH(sshClient)
1020+
require.NoError(t, err)
1021+
defer scpClient.Close()
1022+
tempFile := filepath.Join(t.TempDir(), "scp")
1023+
err = scpClient.CopyFile(context.Background(), strings.NewReader("hello world"), tempFile, "0755")
1024+
require.Error(t, err)
1025+
assertFileTransferBlocked(t, err.Error())
1026+
})
1027+
1028+
t.Run("Forbidden commands", func(t *testing.T) {
1029+
t.Parallel()
1030+
1031+
for _, c := range agentssh.BlockedFileTransferCommands {
1032+
t.Run(c, func(t *testing.T) {
1033+
t.Parallel()
1034+
1035+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
1036+
defer cancel()
1037+
1038+
//nolint:dogsled
1039+
conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) {
1040+
o.BlockFileTransfer = true
1041+
})
1042+
sshClient, err := conn.SSHClient(ctx)
1043+
require.NoError(t, err)
1044+
defer sshClient.Close()
1045+
1046+
session, err := sshClient.NewSession()
1047+
require.NoError(t, err)
1048+
defer session.Close()
1049+
1050+
stdout, err := session.StdoutPipe()
1051+
require.NoError(t, err)
1052+
1053+
//nolint:govet // we don't need `c := c` in Go 1.22
1054+
err = session.Start(c)
1055+
require.NoError(t, err)
1056+
defer session.Close()
1057+
1058+
msg, err := io.ReadAll(stdout)
1059+
require.NoError(t, err)
1060+
assertFileTransferBlocked(t, string(msg))
1061+
})
1062+
}
1063+
})
1064+
}
1065+
9731066
func TestAgent_EnvironmentVariables(t *testing.T) {
9741067
t.Parallel()
9751068
key := "EXAMPLE"

agent/agentssh/agentssh.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,16 @@ const (
5252
// MagicProcessCmdlineJetBrains is a string in a process's command line that
5353
// uniquely identifies it as JetBrains software.
5454
MagicProcessCmdlineJetBrains = "idea.vendor.name=JetBrains"
55+
56+
// BlockedFileTransferErrorCode indicates that SSH server restricted the raw command from performing
57+
// the file transfer.
58+
BlockedFileTransferErrorCode = 65 // Error code: host not allowed to connect
59+
BlockedFileTransferErrorMessage = "File transfer has been disabled."
5560
)
5661

62+
// BlockedFileTransferCommands contains a list of restricted file transfer commands.
63+
var BlockedFileTransferCommands = []string{"nc", "rsync", "scp", "sftp"}
64+
5765
// Config sets configuration parameters for the agent SSH server.
5866
type Config struct {
5967
// MaxTimeout sets the absolute connection timeout, none if empty. If set to
@@ -74,6 +82,8 @@ type Config struct {
7482
// X11SocketDir is the directory where X11 sockets are created. Default is
7583
// /tmp/.X11-unix.
7684
X11SocketDir string
85+
// BlockFileTransfer restricts use of file transfer applications.
86+
BlockFileTransfer bool
7787
}
7888

7989
type Server struct {
@@ -272,6 +282,18 @@ func (s *Server) sessionHandler(session ssh.Session) {
272282
extraEnv = append(extraEnv, fmt.Sprintf("DISPLAY=:%d.0", x11.ScreenNumber))
273283
}
274284

285+
if s.fileTransferBlocked(session) {
286+
s.logger.Warn(ctx, "file transfer blocked", slog.F("session_subsystem", session.Subsystem()), slog.F("raw_command", session.RawCommand()))
287+
288+
if session.Subsystem() == "" { // sftp does not expect error, otherwise it fails with "package too long"
289+
// Response format: <status_code><message body>\n
290+
errorMessage := fmt.Sprintf("\x02%s\n", BlockedFileTransferErrorMessage)
291+
_, _ = session.Write([]byte(errorMessage))
292+
}
293+
_ = session.Exit(BlockedFileTransferErrorCode)
294+
return
295+
}
296+
275297
switch ss := session.Subsystem(); ss {
276298
case "":
277299
case "sftp":
@@ -322,6 +344,37 @@ func (s *Server) sessionHandler(session ssh.Session) {
322344
_ = session.Exit(0)
323345
}
324346

347+
// fileTransferBlocked method checks if the file transfer commands should be blocked.
348+
//
349+
// Warning: consider this mechanism as "Do not trespass" sign, as a violator can still ssh to the host,
350+
// smuggle the `scp` binary, or just manually send files outside with `curl` or `ftp`.
351+
// If a user needs a more sophisticated and battle-proof solution, consider full endpoint security.
352+
func (s *Server) fileTransferBlocked(session ssh.Session) bool {
353+
if !s.config.BlockFileTransfer {
354+
return false // file transfers are permitted
355+
}
356+
// File transfers are restricted.
357+
358+
if session.Subsystem() == "sftp" {
359+
return true
360+
}
361+
362+
cmd := session.Command()
363+
if len(cmd) == 0 {
364+
return false // no command?
365+
}
366+
367+
c := cmd[0]
368+
c = filepath.Base(c) // in case the binary is absolute path, /usr/sbin/scp
369+
370+
for _, cmd := range BlockedFileTransferCommands {
371+
if cmd == c {
372+
return true
373+
}
374+
}
375+
return false
376+
}
377+
325378
func (s *Server) sessionStart(logger slog.Logger, session ssh.Session, extraEnv []string) (retErr error) {
326379
ctx := session.Context()
327380
env := append(session.Environ(), extraEnv...)

cli/agent.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"cdr.dev/slog/sloggers/slogstackdriver"
2828
"github.com/coder/coder/v2/agent"
2929
"github.com/coder/coder/v2/agent/agentproc"
30+
"github.com/coder/coder/v2/agent/agentssh"
3031
"github.com/coder/coder/v2/agent/reaper"
3132
"github.com/coder/coder/v2/buildinfo"
3233
"github.com/coder/coder/v2/codersdk"
@@ -48,6 +49,7 @@ func (r *RootCmd) workspaceAgent() *serpent.Command {
4849
slogHumanPath string
4950
slogJSONPath string
5051
slogStackdriverPath string
52+
blockFileTransfer bool
5153
)
5254
cmd := &serpent.Command{
5355
Use: "agent",
@@ -314,6 +316,8 @@ func (r *RootCmd) workspaceAgent() *serpent.Command {
314316
// Intentionally set this to nil. It's mainly used
315317
// for testing.
316318
ModifiedProcesses: nil,
319+
320+
BlockFileTransfer: blockFileTransfer,
317321
})
318322

319323
promHandler := agent.PrometheusMetricsHandler(prometheusRegistry, logger)
@@ -417,6 +421,13 @@ func (r *RootCmd) workspaceAgent() *serpent.Command {
417421
Default: "",
418422
Value: serpent.StringOf(&slogStackdriverPath),
419423
},
424+
{
425+
Flag: "block-file-transfer",
426+
Default: "false",
427+
Env: "CODER_AGENT_BLOCK_FILE_TRANSFER",
428+
Description: fmt.Sprintf("Block file transfer using known applications: %s.", strings.Join(agentssh.BlockedFileTransferCommands, ",")),
429+
Value: serpent.BoolOf(&blockFileTransfer),
430+
},
420431
}
421432

422433
return cmd

cli/testdata/coder_agent_--help.golden

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ OPTIONS:
1818
--auth string, $CODER_AGENT_AUTH (default: token)
1919
Specify the authentication type to use for the agent.
2020

21+
--block-file-transfer bool, $CODER_AGENT_BLOCK_FILE_TRANSFER (default: false)
22+
Block file transfer using known applications: nc,rsync,scp,sftp.
23+
2124
--debug-address string, $CODER_AGENT_DEBUG_ADDRESS (default: 127.0.0.1:2113)
2225
The bind address to serve a debug HTTP server.
2326

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy