From 6af854dfbc78c098142b4b630b54f05f03c08bf9 Mon Sep 17 00:00:00 2001 From: bernard-portainer Date: Thu, 6 Aug 2026 13:46:58 +1200 Subject: [PATCH] fix(nodeShell): detect bash shell [C9S-298] (#3328) --- api/http/handler/websocket/pod.go | 3 +-- api/ws/command.go | 41 +++++++++++++++++++++++++++++++ api/ws/command_test.go | 25 +++++++++++++++++++ webpack/webpack.common.js | 1 + 4 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 api/ws/command.go create mode 100644 api/ws/command_test.go diff --git a/api/http/handler/websocket/pod.go b/api/http/handler/websocket/pod.go index bfd1e4bf3f..2e5f5b35a8 100644 --- a/api/http/handler/websocket/pod.go +++ b/api/http/handler/websocket/pod.go @@ -4,7 +4,6 @@ import ( "errors" "io" "net/http" - "strings" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" @@ -123,7 +122,7 @@ func (handler *Handler) hijackPodExecStartOperation( endpoint *portainer.Endpoint, namespace, podName, containerName, command string, ) *httperror.HandlerError { - commandArray := strings.Split(command, " ") + commandArray := ws.SplitExecCommand(command) websocketConn, err := handler.connectionUpgrader.Upgrade(w, r, nil) if err != nil { diff --git a/api/ws/command.go b/api/ws/command.go new file mode 100644 index 0000000000..ec9e9784c5 --- /dev/null +++ b/api/ws/command.go @@ -0,0 +1,41 @@ +package ws + +import "strings" + +// SplitExecCommand splits a command string into exec argv, treating a +// single-quoted segment as one argument even if it contains spaces (e.g. +// `sh -c 'echo a b'` stays 3 tokens instead of being shredded on every +// space). Single quotes are not escapable, matching POSIX shell semantics for +// single-quoted strings; there is no support for double quotes since callers +// only ever need one quoted tail argument (a `sh -c` script). +func SplitExecCommand(command string) []string { + var ( + args []string + current strings.Builder + inQuote bool + started bool + ) + + for _, r := range command { + switch { + case r == '\'': + inQuote = !inQuote + started = true + case r == ' ' && !inQuote: + if started { + args = append(args, current.String()) + current.Reset() + started = false + } + default: + current.WriteRune(r) + started = true + } + } + + if started { + args = append(args, current.String()) + } + + return args +} diff --git a/api/ws/command_test.go b/api/ws/command_test.go new file mode 100644 index 0000000000..1c925e0aee --- /dev/null +++ b/api/ws/command_test.go @@ -0,0 +1,25 @@ +package ws + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSplitExecCommand(t *testing.T) { + t.Parallel() + + f := func(input string, expected []string) { + t.Helper() + require.Equal(t, expected, SplitExecCommand(input)) + } + + f("", nil) + f("bash", []string{"bash"}) + f("env TERM=xterm-256color /bin/bash", []string{"env", "TERM=xterm-256color", "/bin/bash"}) + f("sh -c 'echo a b'", []string{"sh", "-c", "echo a b"}) + f("sh -c 'command -v bash >/dev/null 2>&1 && exec bash || exec sh'", + []string{"sh", "-c", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"}) + f("a b", []string{"a", "b"}) + f("'unterminated", []string{"unterminated"}) +} diff --git a/webpack/webpack.common.js b/webpack/webpack.common.js index 14c24838ed..64e63a63e2 100644 --- a/webpack/webpack.common.js +++ b/webpack/webpack.common.js @@ -111,6 +111,7 @@ module.exports = { { context: ['/api'], target: 'http://localhost:9000', + ws: true, }, ], open: true,