CVE-2023-40025: Insufficient Session Expiration (CWE-613)
value: Argo CD is a declarative, GitOps continuous delivery tool for Kubernetes. All versions of Argo CD starting from version 2.6.0 have a bug where open web terminal sessions do not expire. This bug allows users to send any websocket messages even if the token has already expired. The most straightforward scenario is when a user opens the terminal view and leaves it open for an extended period. This allows the user to view sensitive information even when they should have been logged out already. A patch for this vulnerability has been released in the following Argo CD versions: 2.6.14, 2.7.12 and 2.8.1.

CWE description: According to WASC, "Insufficient Session Expiration is when a web site permits an attacker to reuse old session credentials or session IDs for authorization."
CVSS v3 base score: 7.1
Published: 2023-08-23T20:15Z
Repository: https://github.com/argoproj/argo-cd
Language: Go
Files changed: server/application/terminal.go, server/application/websocket.go, server/application/websocket_test.go, server/server.go, ui/src/app/applications/components/pod-terminal-viewer/pod-terminal-viewer.tsx
Commit date: 2023-08-21 16:15:09 -0400
Commit message: Merge pull request from GHSA-c8xw-vjgf-94hr

Signed-off-by: pashakostohrys <pavel@codefresh.io>
Diff: diff --git a/server/application/terminal.go b/server/application/terminal.go
index 6424c89e9..bea1f6ea6 100644
--- a/server/application/terminal.go
+++ b/server/application/terminal.go
@@ -4,10 +4,11 @@ import (
 	"context"
 	"io"
 	"net/http"
 	"time"
 
+	util_session "github.com/argoproj/argo-cd/v2/util/session"
 	"github.com/argoproj/gitops-engine/pkg/utils/kube"
 	log "github.com/sirupsen/logrus"
 	v1 "k8s.io/api/core/v1"
 	apierr "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -35,24 +36,26 @@ type terminalHandler struct {
 	cache             *servercache.Cache
 	appResourceTreeFn func(ctx context.Context, app *appv1.Application) (*appv1.ApplicationTree, error)
 	allowedShells     []string
 	namespace         string
 	enabledNamespaces []string
+	sessionManager    util_session.SessionManager
 }
 
 // NewHandler returns a new terminal handler.
 func NewHandler(appLister applisters.ApplicationLister, namespace string, enabledNamespaces []string, db db.ArgoDB, enf *rbac.Enforcer, cache *servercache.Cache,
-	appResourceTree AppResourceTreeFn, allowedShells []string) *terminalHandler {
+	appResourceTree AppResourceTreeFn, allowedShells []string, sessionManager util_session.SessionManager) *terminalHandler {
 	return &terminalHandler{
 		appLister:         appLister,
 		db:                db,
 		enf:               enf,
 		cache:             cache,
 		appResourceTreeFn: appResourceTree,
 		allowedShells:     allowedShells,
 		namespace:         namespace,
 		enabledNamespaces: enabledNamespaces,
+		sessionManager:    sessionManager,
 	}
 }
 
 func (s *terminalHandler) getApplicationClusterRawConfig(ctx context.Context, a *appv1.Application) (*rest.Config, error) {
 	if err := argo.ValidateDestination(ctx, &a.Spec.Destination, s.db); err != nil {
@@ -220,11 +223,11 @@ func (s *terminalHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
 	fieldLog.Info("terminal session starting")
 
-	session, err := newTerminalSession(w, r, nil)
+	session, err := newTerminalSession(w, r, nil, s.sessionManager)
 	if err != nil {
 		http.Error(w, "Failed to start terminal session", http.StatusBadRequest)
 		return
 	}
 	defer session.Done()
@@ -280,10 +283,15 @@ type TerminalMessage struct {
 	Data      string `json:"data"`
 	Rows      uint16 `json:"rows"`
 	Cols      uint16 `json:"cols"`
 }
 
+// TerminalCommand is the struct for websocket commands,For example you need ask client to reconnect
+type TerminalCommand struct {
+	Code int
+}
+
 // startProcess executes specified commands in the container and connects it up with the ptyHandler (a session)
 func startProcess(k8sClient kubernetes.Interface, cfg *rest.Config, namespace, podName, containerName string, cmd []string, ptyHandler PtyHandler) error {
 	req := k8sClient.CoreV1().RESTClient().Post().
 		Resource("pods").
 		Name(podName).
diff --git a/server/application/websocket.go b/server/application/websocket.go
index fdac5a76c..faee91c4f 100644
--- a/server/application/websocket.go
+++ b/server/application/websocket.go
@@ -1,19 +1,27 @@
 package application
 
 import (
 	"encoding/json"
 	"fmt"
+	"github.com/argoproj/argo-cd/v2/common"
+	httputil "github.com/argoproj/argo-cd/v2/util/http"
+	util_session "github.com/argoproj/argo-cd/v2/util/session"
 	"net/http"
 	"sync"
 	"time"
 
 	"github.com/gorilla/websocket"
 	log "github.com/sirupsen/logrus"
 	"k8s.io/client-go/tools/remotecommand"
 )
 
+const (
+	ReconnectCode    = 1
+	ReconnectMessage = "\nReconnect because the token was refreshed...\n"
+)
+
 var upgrader = func() websocket.Upgrader {
 	upgrader := websocket.Upgrader{}
 	upgrader.HandshakeTimeout = time.Second * 2
 	upgrader.CheckOrigin = func(r *http.Request) bool {
 		return true
@@ -21,29 +29,44 @@ var upgrader = func() websocket.Upgrader {
 	return upgrader
 }()
 
 // terminalSession implements PtyHandler
 type terminalSession struct {
-	wsConn    *websocket.Conn
-	sizeChan  chan remotecommand.TerminalSize
-	doneChan  chan struct{}
-	tty       bool
-	readLock  sync.Mutex
-	writeLock sync.Mutex
+	wsConn         *websocket.Conn
+	sizeChan       chan remotecommand.TerminalSize
+	doneChan       chan struct{}
+	tty            bool
+	readLock       sync.Mutex
+	writeLock      sync.Mutex
+	sessionManager util_session.SessionManager
+	token          *string
+}
+
+// getToken get auth token from web socket request
+func getToken(r *http.Request) (string, error) {
+	cookies := r.Cookies()
+	return httputil.JoinCookies(common.AuthCookieName, cookies)
 }
 
 // newTerminalSession create terminalSession
-func newTerminalSession(w http.ResponseWriter, r *http.Request, responseHeader http.Header) (*terminalSession, error) {
+func newTerminalSession(w http.ResponseWriter, r *http.Request, responseHeader http.Header, sessionManager util_session.SessionManager) (*terminalSession, error) {
+	token, err := getToken(r)
+	if err != nil {
+		return nil, err
+	}
+
 	conn, err := upgrader.Upgrade(w, r, responseHeader)
 	if err != nil {
 		return nil, err
 	}
 	session := &terminalSession{
-		wsConn:   conn,
-		tty:      true,
-		sizeChan: make(chan remotecommand.TerminalSize),
-		doneChan: make(chan struct{}),
+		wsConn:         conn,
+		tty:            true,
+		sizeChan:       make(chan remotecommand.TerminalSize),
+		doneChan:       make(chan struct{}),
+		sessionManager: sessionManager,
+		token:          &token,
 	}
 	return session, nil
 }
 
 // Done close the done channel.
@@ -76,12 +99,44 @@ func (t *terminalSession) Next() *remotecommand.TerminalSize {
 	case <-t.doneChan:
 		return nil
 	}
 }
 
+// reconnect send reconnect code to client and ask them init new ws session
+func (t *terminalSession) reconnect() (int, error) {
+	reconnectCommand, _ := json.Marshal(TerminalCommand{
+		Code: ReconnectCode,
+	})
+	reconnectMessage, _ := json.Marshal(TerminalMessage{
+		Operation: "stdout",
+		Data:      ReconnectMessage,
+	})
+	t.writeLock.Lock()
+	err := t.wsConn.WriteMessage(websocket.TextMessage, reconnectMessage)
+	if err != nil {
+		log.Errorf("write message err: %v", err)
+		return 0, err
+	}
+	err = t.wsConn.WriteMessage(websocket.TextMessage, reconnectCommand)
+	if err != nil {
+		log.Errorf("write message err: %v", err)
+		return 0, err
+	}
+	t.writeLock.Unlock()
+	return 0, nil
+}
+
 // Read called in a loop from remotecommand as long as the process is running
 func (t *terminalSession) Read(p []byte) (int, error) {
+	// check if token still valid
+	_, newToken, err := t.sessionManager.VerifyToken(*t.token)
+	// err in case if token is revoked, newToken in case if refresh happened
+	if err != nil || newToken != "" {
+		// need to send reconnect code in case if token was refreshed
+		return t.reconnect()
+	}
+
 	t.readLock.Lock()
 	_, message, err := t.wsConn.ReadMessage()
 	t.readLock.Unlock()
 	if err != nil {
 		log.Errorf("read message err: %v", err)
diff --git a/server/application/websocket_test.go b/server/application/websocket_test.go
new file mode 100644
index 000000000..30c5ffa23
--- /dev/null
+++ b/server/application/websocket_test.go
@@ -0,0 +1,46 @@
+package application
+
+import (
+	"encoding/json"
+	"github.com/gorilla/websocket"
+	"github.com/stretchr/testify/assert"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+)
+
+func reconnect(w http.ResponseWriter, r *http.Request) {
+	var upgrader = websocket.Upgrader{}
+	c, err := upgrader.Upgrade(w, r, nil)
+	if err != nil {
+		return
+	}
+
+	ts := terminalSession{wsConn: c}
+	_, _ = ts.reconnect()
+}
+
+func TestReconnect(t *testing.T) {
+
+	s := httptest.NewServer(http.HandlerFunc(reconnect))
+	defer s.Close()
+
+	u := "ws" + strings.TrimPrefix(s.URL, "http")
+
+	// Connect to the server
+	ws, _, err := websocket.DefaultDialer.Dial(u, nil)
+	assert.NoError(t, err)
+
+	defer ws.Close()
+
+	_, p, _ := ws.ReadMessage()
+
+	var message TerminalMessage
+
+	err = json.Unmarshal(p, &message)
+
+	assert.NoError(t, err)
+	assert.Equal(t, message.Data, ReconnectMessage)
+
+}
diff --git a/server/server.go b/server/server.go
index e7e3ffb35..28a1da6ec 100644
--- a/server/server.go
+++ b/server/server.go
@@ -973,11 +973,11 @@ func (a *ArgoCDServer) newHTTPServer(ctx context.Context, port int, grpcWebHandl
 	if a.EnableGZip {
 		handler = compressHandler(handler)
 	}
 	mux.Handle("/api/", handler)
 
-	terminal := application.NewHandler(a.appLister, a.Namespace, a.ApplicationNamespaces, a.db, a.enf, a.Cache, appResourceTreeFn, a.settings.ExecShells).
+	terminal := application.NewHandler(a.appLister, a.Namespace, a.ApplicationNamespaces, a.db, a.enf, a.Cache, appResourceTreeFn, a.settings.ExecShells, *a.sessionMgr).
 		WithFeatureFlagMiddleware(a.settingsMgr.GetSettings)
 	th := util_session.WithAuthMiddleware(a.DisableAuth, a.sessionMgr, terminal)
 	mux.Handle("/terminal", th)
 
 	// Proxy extension is currently an alpha feature and is disabled
@@ -986,10 +986,11 @@ func (a *ArgoCDServer) newHTTPServer(ctx context.Context, port int, grpcWebHandl
 		// API server won't panic if extensions fail to register. In
 		// this case an error log will be sent and no extension route
 		// will be added in mux.
 		registerExtensions(mux, a)
 	}
+
 	mustRegisterGWHandler(versionpkg.RegisterVersionServiceHandler, ctx, gwmux, conn)
 	mustRegisterGWHandler(clusterpkg.RegisterClusterServiceHandler, ctx, gwmux, conn)
 	mustRegisterGWHandler(applicationpkg.RegisterApplicationServiceHandler, ctx, gwmux, conn)
 	mustRegisterGWHandler(applicationsetpkg.RegisterApplicationSetServiceHandler, ctx, gwmux, conn)
 	mustRegisterGWHandler(notificationpkg.RegisterNotificationServiceHandler, ctx, gwmux, conn)
diff --git a/ui/src/app/applications/components/pod-terminal-viewer/pod-terminal-viewer.tsx b/ui/src/app/applications/components/pod-terminal-viewer/pod-terminal-viewer.tsx
index 9fe2cf248..72f18f1f8 100644
--- a/ui/src/app/applications/components/pod-terminal-viewer/pod-terminal-viewer.tsx
+++ b/ui/src/app/applications/components/pod-terminal-viewer/pod-terminal-viewer.tsx
@@ -70,11 +70,18 @@ export const PodTerminalViewer: React.FC<PodTerminalViewerProps> = ({
         }
     };
 
     const onConnectionMessage = (e: MessageEvent) => {
         const msg = JSON.parse(e.data);
-        connSubject.next(msg);
+        if (!msg?.Code) {
+            connSubject.next(msg);
+        } else {
+            // Do reconnect due to refresh token event
+            onConnectionClose();
+            setupConnection()
+        }
+
     };
 
     const onConnectionOpen = () => {
         connected = true;
         onTerminalResize(); // fit the screen first time

Vulnerable code: 	appResourceTree AppResourceTreeFn, allowedShells []string) *terminalHandler {
	session, err := newTerminalSession(w, r, nil)
	wsConn    *websocket.Conn
	sizeChan  chan remotecommand.TerminalSize
	doneChan  chan struct{}
	tty       bool
	readLock  sync.Mutex
	writeLock sync.Mutex
func newTerminalSession(w http.ResponseWriter, r *http.Request, responseHeader http.Header) (*terminalSession, error) {
		wsConn:   conn,
		tty:      true,
		sizeChan: make(chan remotecommand.TerminalSize),
		doneChan: make(chan struct{}),
	terminal := application.NewHandler(a.appLister, a.Namespace, a.ApplicationNamespaces, a.db, a.enf, a.Cache, appResourceTreeFn, a.settings.ExecShells).
        connSubject.next(msg);
Fixed code: 	util_session "github.com/argoproj/argo-cd/v2/util/session"
	sessionManager    util_session.SessionManager
	appResourceTree AppResourceTreeFn, allowedShells []string, sessionManager util_session.SessionManager) *terminalHandler {
		sessionManager:    sessionManager,
	session, err := newTerminalSession(w, r, nil, s.sessionManager)
// TerminalCommand is the struct for websocket commands,For example you need ask client to reconnect
type TerminalCommand struct {
	Code int
}

	"github.com/argoproj/argo-cd/v2/common"
	httputil "github.com/argoproj/argo-cd/v2/util/http"
	util_session "github.com/argoproj/argo-cd/v2/util/session"
const (
	ReconnectCode    = 1
	ReconnectMessage = "\nReconnect because the token was refreshed...\n"
)

	wsConn         *websocket.Conn
	sizeChan       chan remotecommand.TerminalSize
	doneChan       chan struct{}
	tty            bool
	readLock       sync.Mutex
	writeLock      sync.Mutex
	sessionManager util_session.SessionManager
	token          *string
}

// getToken get auth token from web socket request
func getToken(r *http.Request) (string, error) {
	cookies := r.Cookies()
	return httputil.JoinCookies(common.AuthCookieName, cookies)
func newTerminalSession(w http.ResponseWriter, r *http.Request, responseHeader http.Header, sessionManager util_session.SessionManager) (*terminalSession, error) {
	token, err := getToken(r)
	if err != nil {
		return nil, err
	}

		wsConn:         conn,
		tty:            true,
		sizeChan:       make(chan remotecommand.TerminalSize),
		doneChan:       make(chan struct{}),
		sessionManager: sessionManager,
		token:          &token,
// reconnect send reconnect code to client and ask them init new ws session
func (t *terminalSession) reconnect() (int, error) {
	reconnectCommand, _ := json.Marshal(TerminalCommand{
		Code: ReconnectCode,
	})
	reconnectMessage, _ := json.Marshal(TerminalMessage{
		Operation: "stdout",
		Data:      ReconnectMessage,
	})
	t.writeLock.Lock()
	err := t.wsConn.WriteMessage(websocket.TextMessage, reconnectMessage)
	if err != nil {
		log.Errorf("write message err: %v", err)
		return 0, err
	}
	err = t.wsConn.WriteMessage(websocket.TextMessage, reconnectCommand)
	if err != nil {
		log.Errorf("write message err: %v", err)
		return 0, err
	}
	t.writeLock.Unlock()
	return 0, nil
}

	// check if token still valid
	_, newToken, err := t.sessionManager.VerifyToken(*t.token)
	// err in case if token is revoked, newToken in case if refresh happened
	if err != nil || newToken != "" {
		// need to send reconnect code in case if token was refreshed
		return t.reconnect()
	}

package application

import (
	"encoding/json"
	"github.com/gorilla/websocket"
	"github.com/stretchr/testify/assert"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

func reconnect(w http.ResponseWriter, r *http.Request) {
	var upgrader = websocket.Upgrader{}
	c, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		return
	}

	ts := terminalSession{wsConn: c}
	_, _ = ts.reconnect()
}

func TestReconnect(t *testing.T) {

	s := httptest.NewServer(http.HandlerFunc(reconnect))
	defer s.Close()

	u := "ws" + strings.TrimPrefix(s.URL, "http")

	// Connect to the server
	ws, _, err := websocket.DefaultDialer.Dial(u, nil)
	assert.NoError(t, err)

	defer ws.Close()

	_, p, _ := ws.ReadMessage()

	var message TerminalMessage

	err = json.Unmarshal(p, &message)

	assert.NoError(t, err)
	assert.Equal(t, message.Data, ReconnectMessage)

}
	terminal := application.NewHandler(a.appLister, a.Namespace, a.ApplicationNamespaces, a.db, a.enf, a.Cache, appResourceTreeFn, a.settings.ExecShells, *a.sessionMgr).

        if (!msg?.Code) {
            connSubject.next(msg);
        } else {
            // Do reconnect due to refresh token event
            onConnectionClose();
            setupConnection()
        }