CVE-2024-5711: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: A stored Cross-Site Scripting (XSS) vulnerability exists in the stitionai/devika chat feature, allowing attackers to inject malicious payloads into the chat input. This vulnerability is due to the lack of input validation and sanitization on both the frontend and backend components of the application. Specifically, the application fails to sanitize user input in the chat feature, leading to the execution of arbitrary JavaScript code in the context of the user's browser session. This issue affects all versions of the application. The impact of this vulnerability includes the potential for stolen credentials, extraction of sensitive information from chat logs, projects, and other data accessible through the application.
CWE description: The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
CVSS v3 base score: 6.1
Published: 2024-07-08T00:15Z
Repository: https://github.com/stitionai/devika
Language: Python
Files changed: devika.py, src/apis/project.py, src/project.py, src/state.py, ui/package.json, ui/src/lib/components/MessageInput.svelte
Commit date: 2024-06-08 17:30:55 +0530
Commit message: Fix: Sanitizing HTML inputs and Cors misconfiguration

Diff: diff --git a/devika.py b/devika.py
index 900a487..9224f54 100644
--- a/devika.py
+++ b/devika.py
@@ -24,11 +24,11 @@ from src.state import AgentState
 from src.agents import Agent
 from src.llm import LLM
 
 
 app = Flask(__name__)
-CORS(app)
+CORS(app, resources={r"/*": {"origins": ["https://localhost:3000"]}}) # Change the origin to your frontend URL
 app.register_blueprint(project_bp)
 socketio.init_app(app)
 
 
 log = logging.getLogger("werkzeug")
@@ -114,18 +114,10 @@ def get_agent_state():
     project_name = data.get("project_name")
     agent_state = AgentState.get_latest_state(project_name)
     return jsonify({"state": agent_state})
 
 
-@app.route("/api/get-project-files/", methods=["GET"])
-@route_logger(logger)
-def project_files():
-    project_name = request.args.get("project_name")
-    files = AgentState.get_project_files(project_name)  
-    return jsonify({"files": files})
-
-
 @app.route("/api/get-browser-snapshot", methods=["GET"])
 @route_logger(logger)
 def browser_snapshot():
     snapshot_path = request.args.get("snapshot_path")
     return send_file(snapshot_path, as_attachment=True)
diff --git a/src/apis/project.py b/src/apis/project.py
index 2f23683..bcebcd1 100644
--- a/src/apis/project.py
+++ b/src/apis/project.py
@@ -1,6 +1,7 @@
 from flask import blueprints, request, jsonify, send_file, make_response
+from werkzeug.utils import secure_filename
 from src.logger import Logger, route_logger
 from src.config import Config
 from src.project import ProjectManager
 from ..state import AgentState
 
@@ -11,42 +12,50 @@ project_bp = blueprints.Blueprint("project", __name__)
 logger = Logger()
 manager = ProjectManager()
 
 
 # Project APIs
+
+@project_bp.route("/api/get-project-files", methods=["GET"])
+@route_logger(logger)
+def project_files():
+    project_name = secure_filename(request.args.get("project_name"))
+    files = manager.get_project_files(project_name)  
+    return jsonify({"files": files})
+
 @project_bp.route("/api/create-project", methods=["POST"])
 @route_logger(logger)
 def create_project():
     data = request.json
     project_name = data.get("project_name")
-    manager.create_project(project_name)
+    manager.create_project(secure_filename(project_name))
     return jsonify({"message": "Project created"})
 
 
 @project_bp.route("/api/delete-project", methods=["POST"])
 @route_logger(logger)
 def delete_project():
     data = request.json
-    project_name = data.get("project_name")
+    project_name = secure_filename(data.get("project_name"))
     manager.delete_project(project_name)
     AgentState().delete_state(project_name)
     return jsonify({"message": "Project deleted"})
 
 
 @project_bp.route("/api/download-project", methods=["GET"])
 @route_logger(logger)
 def download_project():
-    project_name = request.args.get("project_name")
+    project_name = secure_filename(request.args.get("project_name"))
     manager.project_to_zip(project_name)
     project_path = manager.get_zip_path(project_name)
     return send_file(project_path, as_attachment=False)
 
 
 @project_bp.route("/api/download-project-pdf", methods=["GET"])
 @route_logger(logger)
 def download_project_pdf():
-    project_name = request.args.get("project_name")
+    project_name = secure_filename(request.args.get("project_name"))
     pdf_dir = Config().get_pdfs_dir()
     pdf_path = os.path.join(pdf_dir, f"{project_name}.pdf")
 
     response = make_response(send_file(pdf_path))
     response.headers['Content-Type'] = 'project_bplication/pdf'
diff --git a/src/project.py b/src/project.py
index b535c4e..dd2c68b 100644
--- a/src/project.py
+++ b/src/project.py
@@ -142,5 +142,34 @@ class ProjectManager:
 
         return zip_path
 
     def get_zip_path(self, project: str):
         return f"{self.get_project_path(project)}.zip"
+    
+    def get_project_files(self, project_name: str):
+        if not project_name:
+            return []
+
+        project_directory = "-".join(project_name.split(" "))
+        base_path = os.path.abspath(os.path.join(os.getcwd(), 'data', 'projects'))
+        directory = os.path.join(base_path, project_directory)
+
+        # Ensure the directory is within the allowed base path
+        if not os.path.exists(directory) or not os.path.commonprefix([directory, base_path]) == base_path:
+            return []
+
+        files = []
+        for root, _, filenames in os.walk(directory):
+            for filename in filenames:
+                file_relative_path = os.path.relpath(root, directory)
+                if file_relative_path == '.':
+                    file_relative_path = ''
+                file_path = os.path.join(file_relative_path, filename)
+                try:
+                    with open(os.path.join(root, filename), 'r') as file:
+                        files.append({
+                            "file": file_path,
+                            "code": file.read()
+                        })
+                except Exception as e:
+                    print(f"Error reading file {filename}: {e}")
+        return files
diff --git a/src/state.py b/src/state.py
index 5aefdec..1e5c349 100644
--- a/src/state.py
+++ b/src/state.py
@@ -172,30 +172,6 @@ class AgentState:
         with Session(self.engine) as session:
             agent_state = session.query(AgentStateModel).filter(AgentStateModel.project == project).first()
             if agent_state:
                 return json.loads(agent_state.state_stack_json)[-1]["token_usage"]
             return 0
-
-    def get_project_files(self, project_name: str):
-        if not project_name:
-            return []
-        project_directory = "-".join(project_name.split(" "))
-        directory = os.path.join(os.getcwd(), 'data', 'projects', project_directory) 
-        if(not os.path.exists(directory)):
-            return []
-        files = []
-        for root, _, filenames in os.walk(directory):
-            for filename in filenames:
-                file_relative_path = os.path.relpath(root, directory)
-                if file_relative_path == '.': file_relative_path = ''
-                file_path = os.path.join(file_relative_path, filename)
-                print("file_path",file_path)
-                try:
-                    with open(os.path.join(root, filename), 'r') as file:
-                        print("File:", filename)
-                        files.append({
-                            "file": file_path,
-                            "code": file.read()
-                        })
-                except Exception as e:
-                    print(f"Error reading file {filename}: {e}")
-        return files
\ No newline at end of file
+        
\ No newline at end of file
diff --git a/ui/bun.lockb b/ui/bun.lockb
index b15943b..f6fdf25 100755
Binary files a/ui/bun.lockb and b/ui/bun.lockb differ
diff --git a/ui/package.json b/ui/package.json
index 4537347..f027961 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -26,10 +26,11 @@
   "dependencies": {
     "@xterm/addon-fit": "^0.10.0",
     "@xterm/xterm": "^5.5.0",
     "bits-ui": "^0.21.2",
     "clsx": "^2.1.0",
+    "dompurify": "^3.1.5",
     "mode-watcher": "^0.3.0",
     "paneforge": "^0.0.3",
     "socket.io-client": "^4.7.5",
     "svelte-sonner": "^0.3.21",
     "tailwind-merge": "^2.2.2",
diff --git a/ui/src/lib/components/MessageInput.svelte b/ui/src/lib/components/MessageInput.svelte
index 2b541c3..635a9a8 100644
--- a/ui/src/lib/components/MessageInput.svelte
+++ b/ui/src/lib/components/MessageInput.svelte
@@ -1,6 +1,7 @@
 <script>
+  import DOMPurify from "dompurify";
   import { emitMessage, socketListener } from "$lib/sockets";
   import { agentState, messages, isSending } from "$lib/store";
   import { calculateTokens } from "$lib/token";
   import { onMount } from "svelte";
   import { Icons } from "../icons";
@@ -9,16 +10,31 @@
 
   agentState.subscribe((value) => {
     if (value !== null && value.agent_is_active == false) {
       isSending.set(false);
     }
-    if (value == null){
+    if (value == null) {
       inference_time = 0;
     }
   });
 
   let messageInput = "";
+
+  // Function to escape HTML
+  function escapeHTML(input) {
+    const map = {
+      "&": "&amp;",
+      "<": "&lt;",
+      ">": "&gt;",
+      '"': "&quot;",
+      "'": "&#039;",
+    };
+    return input.replace(/[&<>"']/g, function (m) {
+      return map[m];
+    });
+  }
+
   async function handleSendMessage() {
     const projectName = localStorage.getItem("selectedProject");
     const selectedModel = localStorage.getItem("selectedModel");
     const serachEngine = localStorage.getItem("selectedSearchEngine");
 
@@ -29,30 +45,33 @@
     if (!selectedModel) {
       alert("Please select a model first!");
       return;
     }
 
-    if (messageInput.trim() !== "" && isSending) {
+    const sanitizedMessage = DOMPurify.sanitize(messageInput);
+    const escapedMessage = escapeHTML(sanitizedMessage);
+
+
+    if (messageInput.trim() !== "" && escapedMessage.trim() !== "" && isSending) {
       $isSending = true;
-      emitMessage("user-message", { 
-        message: messageInput,
+      emitMessage("user-message", {
+        message: escapedMessage,
         base_model: selectedModel,
         project_name: projectName,
         search_engine: serachEngine,
       });
       messageInput = "";
-      
     }
   }
   onMount(() => {
     socketListener("inference", function (data) {
-      if(data['type'] == 'time') {
+      if (data["type"] == "time") {
         inference_time = data["elapsed_time"];
       }
     });
   });
-       
+
   function setTokenSize(event) {
     const prompt = event.target.value;
     let tokens = calculateTokens(prompt);
     document.querySelector(".token-count").textContent = `${tokens}`;
   }
@@ -71,45 +90,45 @@
       {:else}
         Deactive
       {/if}
     </div>
     <!-- {#if $agentState !== null} -->
-      <div class="px-1 rounded-md text-xs">
-        Model Inference: <span class="text-orange-600">{inference_time} sec</span>
-      </div>
+    <div class="px-1 rounded-md text-xs">
+      Model Inference: <span class="text-orange-600">{inference_time} sec</span>
+    </div>
     <!-- {/if} -->
   </div>
 
-<div class="expandable-input relative">
-  <textarea
-    id="message-input"
-    class="w-full p-4 font-medium focus:text-foreground rounded-xl outline-none h-28 pr-20 bg-secondary
-    {$isSending ? 'cursor-not-allowed' : ''}"   
-    placeholder="Type your message..."
-    disabled={$isSending}
-    bind:value={messageInput}
-    on:input={setTokenSize}
-    on:keydown={(e) => {
-      if (e.key === "Enter" && !e.shiftKey) {
-        e.preventDefault();
-        handleSendMessage();
-        document.querySelector('.token-count').textContent = 0;
-      }
-    }}
-  ></textarea>
-  <button 
-    on:click={handleSendMessage}
-    disabled={$isSending}
-    class="absolute text-secondary bg-primary p-2 right-4 bottom-6 rounded-full
+  <div class="expandable-input relative">
+    <textarea
+      id="message-input"
+      class="w-full p-4 font-medium focus:text-foreground rounded-xl outline-none h-28 pr-20 bg-secondary
     {$isSending ? 'cursor-not-allowed' : ''}"
-  >
-  {@html Icons.CornerDownLeft} 
-  </button>
-  <p class="absolute text-tertiary p-2 right-4 top-2">
-    <span class="token-count">0</span>
-  </p>
-</div>
+      placeholder="Type your message..."
+      disabled={$isSending}
+      bind:value={messageInput}
+      on:input={setTokenSize}
+      on:keydown={(e) => {
+        if (e.key === "Enter" && !e.shiftKey) {
+          e.preventDefault();
+          handleSendMessage();
+          document.querySelector(".token-count").textContent = 0;
+        }
+      }}
+    ></textarea>
+    <button
+      on:click={handleSendMessage}
+      disabled={$isSending}
+      class="absolute text-secondary bg-primary p-2 right-4 bottom-6 rounded-full
+    {$isSending ? 'cursor-not-allowed' : ''}"
+    >
+      {@html Icons.CornerDownLeft}
+    </button>
+    <p class="absolute text-tertiary p-2 right-4 top-2">
+      <span class="token-count">0</span>
+    </p>
+  </div>
 </div>
 
 <style>
   .expandable-input textarea {
     min-height: 60px;

Vulnerable code: CORS(app)
@app.route("/api/get-project-files/", methods=["GET"])
@route_logger(logger)
def project_files():
    project_name = request.args.get("project_name")
    files = AgentState.get_project_files(project_name)  
    return jsonify({"files": files})


    manager.create_project(project_name)
    project_name = data.get("project_name")
    project_name = request.args.get("project_name")
    project_name = request.args.get("project_name")

    def get_project_files(self, project_name: str):
        if not project_name:
            return []
        project_directory = "-".join(project_name.split(" "))
        directory = os.path.join(os.getcwd(), 'data', 'projects', project_directory) 
        if(not os.path.exists(directory)):
            return []
        files = []
        for root, _, filenames in os.walk(directory):
            for filename in filenames:
                file_relative_path = os.path.relpath(root, directory)
                if file_relative_path == '.': file_relative_path = ''
                file_path = os.path.join(file_relative_path, filename)
                print("file_path",file_path)
                try:
                    with open(os.path.join(root, filename), 'r') as file:
                        print("File:", filename)
                        files.append({
                            "file": file_path,
                            "code": file.read()
                        })
                except Exception as e:
                    print(f"Error reading file {filename}: {e}")
        return files
    if (value == null){
    if (messageInput.trim() !== "" && isSending) {
      emitMessage("user-message", { 
        message: messageInput,
      
      if(data['type'] == 'time') {
       
      <div class="px-1 rounded-md text-xs">
        Model Inference: <span class="text-orange-600">{inference_time} sec</span>
      </div>
<div class="expandable-input relative">
  <textarea
    id="message-input"
    class="w-full p-4 font-medium focus:text-foreground rounded-xl outline-none h-28 pr-20 bg-secondary
    {$isSending ? 'cursor-not-allowed' : ''}"   
    placeholder="Type your message..."
    disabled={$isSending}
    bind:value={messageInput}
    on:input={setTokenSize}
    on:keydown={(e) => {
      if (e.key === "Enter" && !e.shiftKey) {
        e.preventDefault();
        handleSendMessage();
        document.querySelector('.token-count').textContent = 0;
      }
    }}
  ></textarea>
  <button 
    on:click={handleSendMessage}
    disabled={$isSending}
    class="absolute text-secondary bg-primary p-2 right-4 bottom-6 rounded-full
  >
  {@html Icons.CornerDownLeft} 
  </button>
  <p class="absolute text-tertiary p-2 right-4 top-2">
    <span class="token-count">0</span>
  </p>
</div>
Fixed code: CORS(app, resources={r"/*": {"origins": ["https://localhost:3000"]}}) # Change the origin to your frontend URL
from werkzeug.utils import secure_filename

@project_bp.route("/api/get-project-files", methods=["GET"])
@route_logger(logger)
def project_files():
    project_name = secure_filename(request.args.get("project_name"))
    files = manager.get_project_files(project_name)  
    return jsonify({"files": files})

    manager.create_project(secure_filename(project_name))
    project_name = secure_filename(data.get("project_name"))
    project_name = secure_filename(request.args.get("project_name"))
    project_name = secure_filename(request.args.get("project_name"))
    
    def get_project_files(self, project_name: str):
        if not project_name:
            return []

        project_directory = "-".join(project_name.split(" "))
        base_path = os.path.abspath(os.path.join(os.getcwd(), 'data', 'projects'))
        directory = os.path.join(base_path, project_directory)

        # Ensure the directory is within the allowed base path
        if not os.path.exists(directory) or not os.path.commonprefix([directory, base_path]) == base_path:
            return []

        files = []
        for root, _, filenames in os.walk(directory):
            for filename in filenames:
                file_relative_path = os.path.relpath(root, directory)
                if file_relative_path == '.':
                    file_relative_path = ''
                file_path = os.path.join(file_relative_path, filename)
                try:
                    with open(os.path.join(root, filename), 'r') as file:
                        files.append({
                            "file": file_path,
                            "code": file.read()
                        })
                except Exception as e:
                    print(f"Error reading file {filename}: {e}")
        return files
        
    "dompurify": "^3.1.5",
  import DOMPurify from "dompurify";
    if (value == null) {

  // Function to escape HTML
  function escapeHTML(input) {
    const map = {
      "&": "&amp;",
      "<": "&lt;",
      ">": "&gt;",
      '"': "&quot;",
      "'": "&#039;",
    };
    return input.replace(/[&<>"']/g, function (m) {
      return map[m];
    });
  }

    const sanitizedMessage = DOMPurify.sanitize(messageInput);
    const escapedMessage = escapeHTML(sanitizedMessage);


    if (messageInput.trim() !== "" && escapedMessage.trim() !== "" && isSending) {
      emitMessage("user-message", {
        message: escapedMessage,
      if (data["type"] == "time") {

    <div class="px-1 rounded-md text-xs">
      Model Inference: <span class="text-orange-600">{inference_time} sec</span>
    </div>
  <div class="expandable-input relative">
    <textarea
      id="message-input"
      class="w-full p-4 font-medium focus:text-foreground rounded-xl outline-none h-28 pr-20 bg-secondary
      placeholder="Type your message..."
      disabled={$isSending}
      bind:value={messageInput}
      on:input={setTokenSize}
      on:keydown={(e) => {
        if (e.key === "Enter" && !e.shiftKey) {
          e.preventDefault();
          handleSendMessage();
          document.querySelector(".token-count").textContent = 0;
        }
      }}
    ></textarea>
    <button
      on:click={handleSendMessage}
      disabled={$isSending}
      class="absolute text-secondary bg-primary p-2 right-4 bottom-6 rounded-full
    {$isSending ? 'cursor-not-allowed' : ''}"
    >
      {@html Icons.CornerDownLeft}
    </button>
    <p class="absolute text-tertiary p-2 right-4 top-2">
      <span class="token-count">0</span>
    </p>
  </div>