CVE-2024-5547: Insufficient Information (NVD-CWE-noinfo)
value: A directory traversal vulnerability exists in the /api/download-project-pdf endpoint of the stitionai/devika repository, affecting the latest version. The vulnerability arises due to insufficient sanitization of the 'project_name' parameter in the download_project_pdf function. Attackers can exploit this flaw by manipulating the 'project_name' parameter in a GET request to traverse the directory structure and download arbitrary PDF files from the system. This issue allows attackers to access sensitive information that could be stored in PDF format outside the intended directory.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
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>