CVE-2024-5548: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') (CWE-22)
value: A directory traversal vulnerability exists in the stitionai/devika repository, specifically within the /api/download-project endpoint. Attackers can exploit this vulnerability by manipulating the 'project_name' parameter in a GET request to download arbitrary files from the system. This issue affects the latest version of the repository. The vulnerability arises due to insufficient input validation in the 'download_project' function, allowing attackers to traverse the directory structure and access files outside the intended directory. This could lead to unauthorized access to sensitive files on the server.
CWE description: The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory.
Published: 2024-06-27T18: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>