CVE-2023-31143: Missing Authentication for Critical Function (CWE-306)
value: mage-ai is an open-source data pipeline tool for transforming and integrating data. Those who use Mage starting in version 0.8.34 and prior to 0.8.72 with user authentication enabled may be affected by a vulnerability. The terminal could be accessed by users who are not signed in or do not have editor permissions. Version 0.8.72 contains a fix for this issue.
CWE description: The product does not perform any authentication for functionality that requires a provable user identity or consumes a significant amount of resources.
CVSS v3 base score: 9.8
Published: 2023-05-09T15:15Z
Repository: https://github.com/mage-ai/mage-ai
Language: Python
Files changed: mage_ai/frontend/components/Terminal/index.tsx, mage_ai/server/terminal_server.py
Commit date: 2023-05-04 11:26:49 -0700
Commit message: [dy] Fix terminal user authentication (#2586)

* [dy] Update terminal user authentication

* [dy] Update auth
Diff: diff --git a/mage_ai/frontend/components/Terminal/index.tsx b/mage_ai/frontend/components/Terminal/index.tsx
index 43255a16..bdee827a 100644
--- a/mage_ai/frontend/components/Terminal/index.tsx
+++ b/mage_ai/frontend/components/Terminal/index.tsx
@@ -1,8 +1,9 @@
 import Ansi from 'ansi-to-react';
 import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 
+import AuthToken from '@api/utils/AuthToken';
 import ClickOutside from '@oracle/components/ClickOutside';
 import Text from '@oracle/elements/Text';
 import {
   CharacterStyle,
   ContainerStyle,
@@ -24,10 +25,11 @@ import {
   KEY_CODE_CONTROL,
   KEY_CODE_ENTER,
   KEY_CODE_META,
   KEY_CODE_V,
 } from '@utils/hooks/keyboardShortcuts/constants';
+import { OAUTH2_APPLICATION_CLIENT_ID } from '@api/constants';
 import { onlyKeysPresent } from '@utils/hooks/keyboardShortcuts/utils';
 import { pauseEvent } from '@utils/events';
 import { useKeyboardContext } from '@context/Keyboard';
 
 export const DEFAULT_TERMINAL_UUID = 'terminal';
@@ -56,10 +58,18 @@ function Terminal({
   const [commandHistory, setCommandHistory] = useState<string[]>([]);
   const [focus, setFocus] = useState<boolean>(false);
 
   const [stdout, setStdout] = useState<string>();
 
+  const token = useMemo(() => new AuthToken(), []);
+  const oauthWebsocketData = useMemo(() => ({
+    api_key: OAUTH2_APPLICATION_CLIENT_ID,
+    token: token.decodedToken.token,
+  }), [
+    token,
+  ]);
+
   useEffect(() => {
     if (lastMessage) {
       const msg = JSON.parse(lastMessage.data);
 
       setStdout(prev => {
@@ -120,16 +130,14 @@ function Terminal({
   const increaseCursorIndex = useCallback(() => {
     setCursorIndex(currIdx => (currIdx < command.length) ? currIdx + 1 : currIdx);
   }, [command]);
 
   const sendCommand = useCallback((cmd) => {
-    sendMessage(JSON.stringify([
-      'stdin', cmd,
-    ]));
-    sendMessage(JSON.stringify([
-      'stdin', '\r',
-    ]));
+    sendMessage(JSON.stringify({
+      ...oauthWebsocketData,
+      command: ['stdin', cmd + '\r'],
+    }));
     if (cmd?.length >= 2) {
       setCommandIndex(commandHistory.length + 1);
       setCommandHistory(prev => prev.concat(cmd));
       setCursorIndex(0);
     }
@@ -172,16 +180,18 @@ function Terminal({
 
       if (focus) {
         pauseEvent(event);
         if (onlyKeysPresent([KEY_CODE_CONTROL, KEY_CODE_C], keyMapping)) {
           if (command?.length >= 0) {
-            sendMessage(JSON.stringify([
-              'stdin', command,
-            ]));
-            sendMessage(JSON.stringify([
-              'stdin', '\x03',
-            ]));
+            sendMessage(JSON.stringify({
+              ...oauthWebsocketData,
+              command: ['stdin', command],
+            }));
+            sendMessage(JSON.stringify({
+              ...oauthWebsocketData,
+              command: ['stdin', '\x03'],
+            }));
             setCursorIndex(0);
           }
           setCommand('');
         } else {
           if (KEY_CODE_BACKSPACE === code && !keyMapping[KEY_CODE_META]) {
diff --git a/mage_ai/server/terminal_server.py b/mage_ai/server/terminal_server.py
index 1828699a..74731131 100644
--- a/mage_ai/server/terminal_server.py
+++ b/mage_ai/server/terminal_server.py
@@ -1,8 +1,16 @@
-from mage_ai.api.utils import authenticate_client_and_token
+from mage_ai.api.utils import (
+    authenticate_client_and_token,
+    has_at_least_editor_role,
+)
 from mage_ai.orchestration.db.models.oauth import Oauth2Application
-from mage_ai.settings import REQUIRE_USER_AUTHENTICATION
+from mage_ai.settings import (
+    is_disable_pipeline_edit_access,
+    REQUIRE_USER_AUTHENTICATION,
+)
+from tornado import gen
+import json
 import terminado
 import tornado.websocket
 import re
 
 
@@ -76,12 +84,10 @@ class TerminalWebsocketServer(terminado.TermSocket):
                 valid = valid and \
                     oauth_token and \
                     oauth_token.user
                 if valid:
                     user = oauth_token.user
-                else:
-                    raise Exception('Invalid token')
 
         self.term_name = term_name if term_name else 'tty'
         if user:
             self.term_name = f'{self.term_name}_{user.id}'
 
@@ -89,10 +95,37 @@ class TerminalWebsocketServer(terminado.TermSocket):
 
         self.terminal = self.term_manager.get_terminal(self.term_name, cwd=cwd)
         self.terminal.clients.append(self)
         self.__initiate_terminal(self.terminal)
 
+    @gen.coroutine
+    def on_message(self, raw_message):
+        message = json.loads(raw_message)
+
+        api_key = message.get('api_key')
+        token = message.get('token')
+        command = message.get('command')
+
+        if REQUIRE_USER_AUTHENTICATION or is_disable_pipeline_edit_access():
+            valid = False
+
+            if api_key and token:
+                oauth_client = Oauth2Application.query.filter(
+                    Oauth2Application.client_id == api_key,
+                ).first()
+                if oauth_client:
+                    oauth_token, valid = authenticate_client_and_token(oauth_client.id, token)
+                    valid = valid and \
+                        oauth_token and \
+                        oauth_token.user and \
+                        has_at_least_editor_role(oauth_token.user)
+            if not valid or is_disable_pipeline_edit_access():
+                return self.send_json_message(
+                    ['stdout', f'{command[1]}\nUnauthorized access to the terminal.'])
+
+        super().on_message(json.dumps(command))
+
     def __initiate_terminal(self, terminal):
         self.send_json_message(["setup", {}])
         self._logger.info("TermSocket.open: Opened %s", self.term_name)
         # Now drain the preopen buffer, if reconnect.
         buffered = ""

Vulnerable code:     sendMessage(JSON.stringify([
      'stdin', cmd,
    ]));
    sendMessage(JSON.stringify([
      'stdin', '\r',
    ]));
            sendMessage(JSON.stringify([
              'stdin', command,
            ]));
            sendMessage(JSON.stringify([
              'stdin', '\x03',
            ]));
from mage_ai.api.utils import authenticate_client_and_token
from mage_ai.settings import REQUIRE_USER_AUTHENTICATION
                else:
                    raise Exception('Invalid token')
Fixed code: import AuthToken from '@api/utils/AuthToken';
import { OAUTH2_APPLICATION_CLIENT_ID } from '@api/constants';
  const token = useMemo(() => new AuthToken(), []);
  const oauthWebsocketData = useMemo(() => ({
    api_key: OAUTH2_APPLICATION_CLIENT_ID,
    token: token.decodedToken.token,
  }), [
    token,
  ]);

    sendMessage(JSON.stringify({
      ...oauthWebsocketData,
      command: ['stdin', cmd + '\r'],
    }));
            sendMessage(JSON.stringify({
              ...oauthWebsocketData,
              command: ['stdin', command],
            }));
            sendMessage(JSON.stringify({
              ...oauthWebsocketData,
              command: ['stdin', '\x03'],
            }));
from mage_ai.api.utils import (
    authenticate_client_and_token,
    has_at_least_editor_role,
)
from mage_ai.settings import (
    is_disable_pipeline_edit_access,
    REQUIRE_USER_AUTHENTICATION,
)
from tornado import gen
import json
    @gen.coroutine
    def on_message(self, raw_message):
        message = json.loads(raw_message)

        api_key = message.get('api_key')
        token = message.get('token')
        command = message.get('command')

        if REQUIRE_USER_AUTHENTICATION or is_disable_pipeline_edit_access():
            valid = False

            if api_key and token:
                oauth_client = Oauth2Application.query.filter(
                    Oauth2Application.client_id == api_key,
                ).first()
                if oauth_client:
                    oauth_token, valid = authenticate_client_and_token(oauth_client.id, token)
                    valid = valid and \
                        oauth_token and \
                        oauth_token.user and \
                        has_at_least_editor_role(oauth_token.user)
            if not valid or is_disable_pipeline_edit_access():
                return self.send_json_message(
                    ['stdout', f'{command[1]}\nUnauthorized access to the terminal.'])

        super().on_message(json.dumps(command))