CVE-2024-1728: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') (CWE-22)
value: gradio-app/gradio is vulnerable to a local file inclusion vulnerability due to improper validation of user-supplied input in the UploadButton component. Attackers can exploit this vulnerability to read arbitrary files on the filesystem, such as private SSH keys, by manipulating the file path in the request to the `/queue/join` endpoint. This issue could potentially lead to remote code execution. The vulnerability is present in the handling of file upload paths, allowing attackers to redirect file uploads to unintended locations 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-04-10T17:15Z
Repository: https://github.com/gradio-app/gradio
Language: Python
Files changed: .changeset/moody-impalas-rule.md, gradio/blocks.py, gradio/processing_utils.py, gradio/routes.py, gradio/utils.py
Commit date: 2024-02-21 20:21:20 -0600
Commit message: Prevent components from working with non-uploaded files (#7465)

* changes

* chanegs

* add changeset

* changes

* changes

* add changeset

* changes

* changes

* changes

---------

Co-authored-by: Ali Abid <aliabid94@gmail.com>
Co-authored-by: gradio-pr-bot <gradio-pr-bot@users.noreply.github.com>
Diff: diff --git a/.changeset/moody-impalas-rule.md b/.changeset/moody-impalas-rule.md
new file mode 100644
index 000000000..5ec57a97e
--- /dev/null
+++ b/.changeset/moody-impalas-rule.md
@@ -0,0 +1,5 @@
+---
+"gradio": patch
+---
+
+feat:Prevent components from working with non-uploaded files
diff --git a/gradio/blocks.py b/gradio/blocks.py
index 616d6af38..1206adff2 100644
--- a/gradio/blocks.py
+++ b/gradio/blocks.py
@@ -7,11 +7,10 @@ import json
 import os
 import random
 import secrets
 import string
 import sys
-import tempfile
 import threading
 import time
 import warnings
 import webbrowser
 from collections import defaultdict
@@ -68,10 +67,11 @@ from gradio.utils import (
     check_function_inputs_match,
     component_or_layout_class,
     get_cancel_function,
     get_continuous_fn,
     get_package_version,
+    get_upload_folder,
 )
 
 try:
     import spaces  # type: ignore
 except Exception:
@@ -117,16 +117,11 @@ class Block:
         self.parent: BlockContext | None = None
         self.is_rendered: bool = False
         self._constructor_args: list[dict]
         self.state_session_capacity = 10000
         self.temp_files: set[str] = set()
-        self.GRADIO_CACHE = str(
-            Path(
-                os.environ.get("GRADIO_TEMP_DIR")
-                or str(Path(tempfile.gettempdir()) / "gradio")
-            ).resolve()
-        )
+        self.GRADIO_CACHE = get_upload_folder()
 
         if render:
             self.render()
 
     @property
@@ -1108,10 +1103,11 @@ class Blocks(BlockContext, BlocksEvents, metaclass=BlocksMeta):
             self.process_api,
             fn_index=fn_index,
             inputs=processed_inputs,
             request=None,
             state={},
+            explicit_call=True,
         )
         outputs = outputs["data"]
 
         if batch:
             outputs = [out[0] for out in outputs]
@@ -1297,11 +1293,15 @@ Wanted inputs:
 Received inputs:
     [{received}]"""
             )
 
     def preprocess_data(
-        self, fn_index: int, inputs: list[Any], state: SessionState | None
+        self,
+        fn_index: int,
+        inputs: list[Any],
+        state: SessionState | None,
+        explicit_call: bool = False,
     ):
         state = state or SessionState(self)
         block_fn = self.fns[fn_index]
         dependency = self.dependencies[fn_index]
 
@@ -1324,11 +1324,14 @@ Received inputs:
                     processed_input.append(state[input_id])
                 else:
                     if input_id in state:
                         block = state[input_id]
                     inputs_cached = processing_utils.move_files_to_cache(
-                        inputs[i], block, add_urls=True
+                        inputs[i],
+                        block,
+                        add_urls=True,
+                        check_in_upload_folder=not explicit_call,
                     )
                     if getattr(block, "data_model", None) and inputs_cached is not None:
                         if issubclass(block.data_model, GradioModel):  # type: ignore
                             inputs_cached = block.data_model(**inputs_cached)  # type: ignore
                         elif issubclass(block.data_model, GradioRootModel):  # type: ignore
@@ -1520,12 +1523,18 @@ Received outputs:
         if final:
             del self.pending_diff_streams[session_hash][run]
 
         return data
 
-    def run_fn_batch(self, fn, batch, fn_index, state):
-        return [fn(fn_index, list(i), state) for i in zip(*batch)]
+    def run_fn_batch(self, fn, batch, fn_index, state, explicit_call=None):
+        output = []
+        for i in zip(*batch):
+            args = [fn_index, list(i), state]
+            if explicit_call is not None:
+                args.append(explicit_call)
+            output.append(fn(*args))
+        return output
 
     async def process_api(
         self,
         fn_index: int,
         inputs: list[Any],
@@ -1534,10 +1543,11 @@ Received outputs:
         iterator: AsyncIterator | None = None,
         session_hash: str | None = None,
         event_id: str | None = None,
         event_data: EventData | None = None,
         in_event_listener: bool = True,
+        explicit_call: bool = False,
     ) -> dict[str, Any]:
         """
         Processes API calls from the frontend. First preprocesses the data,
         then runs the relevant function, then postprocesses the output.
         Parameters:
@@ -1546,10 +1556,12 @@ Received outputs:
             state: data stored from stateful components for session (key is input block id)
             request: the gr.Request object containing information about the network request (e.g. IP address, headers, query parameters, username)
             iterators: the in-progress iterators for each generator function (key is function index)
             event_id: id of event that triggered this API call
             event_data: data associated with the event trigger itself
+            in_event_listener: whether this API call is being made in response to an event listener
+            explicit_call: whether this call is being made directly by calling the Blocks function, instead of through an event listener or API route
         Returns: None
         """
         block_fn = self.fns[fn_index]
         batch = self.dependencies[fn_index]["batch"]
 
@@ -1573,10 +1585,11 @@ Received outputs:
                 self.run_fn_batch,
                 self.preprocess_data,
                 inputs,
                 fn_index,
                 state,
+                explicit_call,
                 limiter=self.limiter,
             )
             result = await self.call_function(
                 fn_index,
                 list(zip(*inputs)),
@@ -1601,11 +1614,16 @@ Received outputs:
             old_iterator = iterator
             if old_iterator:
                 inputs = []
             else:
                 inputs = await anyio.to_thread.run_sync(
-                    self.preprocess_data, fn_index, inputs, state, limiter=self.limiter
+                    self.preprocess_data,
+                    fn_index,
+                    inputs,
+                    state,
+                    explicit_call,
+                    limiter=self.limiter,
                 )
             was_generating = old_iterator is not None
             result = await self.call_function(
                 fn_index,
                 inputs,
diff --git a/gradio/processing_utils.py b/gradio/processing_utils.py
index 5f82f2521..52e9e59f2 100644
--- a/gradio/processing_utils.py
+++ b/gradio/processing_utils.py
@@ -18,11 +18,11 @@ import numpy as np
 from gradio_client import utils as client_utils
 from PIL import Image, ImageOps, PngImagePlugin
 
 from gradio import wasm_utils
 from gradio.data_classes import FileData, GradioModel, GradioRootModel
-from gradio.utils import abspath
+from gradio.utils import abspath, get_upload_folder, is_in_or_equal
 
 with warnings.catch_warnings():
     warnings.simplefilter("ignore")  # Ignore pydub warning if ffmpeg is not installed
     from pydub import AudioSegment
 
@@ -239,10 +239,11 @@ def move_resource_to_block_cache(
 def move_files_to_cache(
     data: Any,
     block: Component,
     postprocess: bool = False,
     add_urls=False,
+    check_in_upload_folder=False,
 ) -> dict:
     """Move any files in `data` to cache and (optionally), adds URL prefixes (/file=...) needed to access the cached file.
     Also handles the case where the file is on an external Gradio app (/proxy=...).
 
     Runs after .postprocess() and before .preprocess().
@@ -250,10 +251,12 @@ def move_files_to_cache(
     Args:
         data: The input or output data for a component. Can be a dictionary or a dataclass
         block: The component whose data is being processed
         postprocess: Whether its running from postprocessing
         root_url: The root URL of the local server, if applicable
+        add_urls: Whether to add URLs to the payload
+        check_in_upload_folder: If True, instead of moving the file to cache, checks if the file is in already in cache (exception if not).
     """
 
     def _move_to_cache(d: dict):
         payload = FileData(**d)
         # If the gradio app developer is returning a URL from
@@ -262,10 +265,18 @@ def move_files_to_cache(
         # This makes it so that the URL is not downloaded and speeds up event processing
         if payload.url and postprocess:
             payload.path = payload.url
         elif not block.proxy_url:
             # If the file is on a remote server, do not move it to cache.
+            if check_in_upload_folder and not client_utils.is_http_url_like(
+                payload.path
+            ):
+                path = os.path.abspath(payload.path)
+                if not is_in_or_equal(path, get_upload_folder()):
+                    raise ValueError(
+                        f"File {path} is not in the upload folder and cannot be accessed."
+                    )
             temp_file_path = block.move_resource_to_block_cache(payload.path)
             if temp_file_path is None:
                 raise ValueError("Did not determine a file path for the resource.")
             payload.path = temp_file_path
 
diff --git a/gradio/routes.py b/gradio/routes.py
index 876636057..29386e59b 100644
--- a/gradio/routes.py
+++ b/gradio/routes.py
@@ -15,11 +15,10 @@ import inspect
 import json
 import mimetypes
 import os
 import posixpath
 import secrets
-import tempfile
 import threading
 import time
 import traceback
 from pathlib import Path
 from queue import Empty as EmptyQueue
@@ -65,13 +64,11 @@ from gradio.route_utils import (  # noqa: F401
     Request,
     compare_passwords_securely,
     move_uploaded_files_to_cache,
 )
 from gradio.state_holder import StateHolder
-from gradio.utils import (
-    get_package_version,
-)
+from gradio.utils import get_package_version, get_upload_folder
 
 if TYPE_CHECKING:
     from gradio.blocks import Block
 
 
@@ -134,13 +131,11 @@ class App(FastAPI):
         self.iterators_to_reset: set[str] = set()
         self.lock = utils.safe_get_lock()
         self.cookie_id = secrets.token_urlsafe(32)
         self.queue_token = secrets.token_urlsafe(32)
         self.startup_events_triggered = False
-        self.uploaded_file_dir = os.environ.get("GRADIO_TEMP_DIR") or str(
-            (Path(tempfile.gettempdir()) / "gradio").resolve()
-        )
+        self.uploaded_file_dir = get_upload_folder()
         self.change_event: None | threading.Event = None
         self._asyncio_tasks: list[asyncio.Task] = []
         # Allow user to manually set `docs_url` and `redoc_url`
         # when instantiating an App; when they're not set, disable docs and redoc.
         kwargs.setdefault("docs_url", None)
diff --git a/gradio/utils.py b/gradio/utils.py
index b3f366b70..78a1819f6 100644
--- a/gradio/utils.py
+++ b/gradio/utils.py
@@ -11,10 +11,11 @@ import inspect
 import json
 import json.decoder
 import os
 import pkgutil
 import re
+import tempfile
 import threading
 import time
 import traceback
 import typing
 import urllib.parse
@@ -1080,5 +1081,11 @@ def diff(old, new):
 
         edits.append(("replace", path, obj2))
         return edits
 
     return compare_objects(old, new)
+
+
+def get_upload_folder() -> str:
+    return os.environ.get("GRADIO_TEMP_DIR") or str(
+        (Path(tempfile.gettempdir()) / "gradio").resolve()
+    )

Vulnerable code: import tempfile
        self.GRADIO_CACHE = str(
            Path(
                os.environ.get("GRADIO_TEMP_DIR")
                or str(Path(tempfile.gettempdir()) / "gradio")
            ).resolve()
        )
        self, fn_index: int, inputs: list[Any], state: SessionState | None
                        inputs[i], block, add_urls=True
    def run_fn_batch(self, fn, batch, fn_index, state):
        return [fn(fn_index, list(i), state) for i in zip(*batch)]
                    self.preprocess_data, fn_index, inputs, state, limiter=self.limiter
from gradio.utils import abspath
import tempfile
from gradio.utils import (
    get_package_version,
)
        self.uploaded_file_dir = os.environ.get("GRADIO_TEMP_DIR") or str(
            (Path(tempfile.gettempdir()) / "gradio").resolve()
        )
Fixed code: ---
"gradio": patch
---

feat:Prevent components from working with non-uploaded files
    get_upload_folder,
        self.GRADIO_CACHE = get_upload_folder()
            explicit_call=True,
        self,
        fn_index: int,
        inputs: list[Any],
        state: SessionState | None,
        explicit_call: bool = False,
                        inputs[i],
                        block,
                        add_urls=True,
                        check_in_upload_folder=not explicit_call,
    def run_fn_batch(self, fn, batch, fn_index, state, explicit_call=None):
        output = []
        for i in zip(*batch):
            args = [fn_index, list(i), state]
            if explicit_call is not None:
                args.append(explicit_call)
            output.append(fn(*args))
        return output
        explicit_call: bool = False,
            in_event_listener: whether this API call is being made in response to an event listener
            explicit_call: whether this call is being made directly by calling the Blocks function, instead of through an event listener or API route
                explicit_call,
                    self.preprocess_data,
                    fn_index,
                    inputs,
                    state,
                    explicit_call,
                    limiter=self.limiter,
from gradio.utils import abspath, get_upload_folder, is_in_or_equal
    check_in_upload_folder=False,
        add_urls: Whether to add URLs to the payload
        check_in_upload_folder: If True, instead of moving the file to cache, checks if the file is in already in cache (exception if not).
            if check_in_upload_folder and not client_utils.is_http_url_like(
                payload.path
            ):
                path = os.path.abspath(payload.path)
                if not is_in_or_equal(path, get_upload_folder()):
                    raise ValueError(
                        f"File {path} is not in the upload folder and cannot be accessed."
                    )
from gradio.utils import get_package_version, get_upload_folder
        self.uploaded_file_dir = get_upload_folder()
import tempfile


def get_upload_folder() -> str:
    return os.environ.get("GRADIO_TEMP_DIR") or str(
        (Path(tempfile.gettempdir()) / "gradio").resolve()
    )