CVE-2024-3078 (published 2024-03-29T13:15Z)

A vulnerability was found in Qdrant up to 1.6.1/1.7.4/1.8.2 and classified as critical. This issue affects some unknown processing of the file lib/collection/src/collection/snapshots.rs of the component Full Snapshot REST API. The manipulation leads to path traversal. Upgrading to version 1.8.3 is able to address this issue. The patch is named 3ab5172e9c8f14fa1f7b24e7147eac74e2412b62. It is recommended to upgrade the affected component. The associated identifier of this vulnerability is VDB-258611.


Weakness information:
cwe_id: CWE-22
cwe_name: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
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.
extended_description: 
            Many file operations are intended to take place within a restricted directory. By using special elements such as ".." and "/" separators, attackers can escape outside of the restricted location to access files or directories that are elsewhere on the system. One of the most common special elements is the "../" sequence, which in most modern operating systems is interpreted as the parent directory of the current location. This is referred to as relative path traversal. Path traversal also covers the use of absolute pathnames such as "/usr/local/bin" to access unexpected files. This is referred to as absolute path traversal.
         
         

Fix information:
repo_url: https://github.com/qdrant/qdrant
commit_details: repo_url: https://github.com/qdrant/qdrant
author: Tim Visée
author_date: 2024-03-18 20:51:17+01:00
msg: Fix arbitrary path traversal vulnerability in full snapshot REST API (#3856)

* Improve function to get collection snapshot path

* Fix arbitrary path traversal in full snapshot path function

* Add integration test to assert arbitrary path traversal is blocked
repository: repo_url: https://github.com/qdrant/qdrant
repo_name: qdrant/qdrant
description: Qdrant - High-performance, massive-scale Vector Database for the next generation of AI. Also available in the cloud https://cloud.qdrant.io/
repo_language: Rust
owner: qdrant
file_changes: filename: snapshots.rs
change_type: ModificationType.MODIFY
diff: @@ -232,35 +232,35 @@ impl Collection {
             .await
     }
 
+    /// Get full file path for a collection snapshot by name
+    ///
+    /// This enforces the file to be inside the snapshots directory
     pub async fn get_snapshot_path(&self, snapshot_name: &str) -> CollectionResult<PathBuf> {
-        let snapshot_path = self.snapshots_path.join(snapshot_name);
-
-        let absolute_snapshot_path =
-            snapshot_path
-                .canonicalize()
-                .map_err(|_| CollectionError::NotFound {
-                    what: format!("Snapshot {snapshot_name}"),
-                })?;
-
-        let absolute_snapshot_dir =
-            self.snapshots_path
-                .canonicalize()
-                .map_err(|_| CollectionError::NotFound {
-                    what: format!("Snapshot directory: {}", self.snapshots_path.display()),
-                })?;
+        let absolute_snapshot_dir = self.snapshots_path.canonicalize().map_err(|_| {
+            CollectionError::not_found(format!(
+                "Snapshot directory: {}",
+                self.snapshots_path.display()
+            ))
+        })?;
+
+        let absolute_snapshot_path = absolute_snapshot_dir
+            .join(snapshot_name)
+            .canonicalize()
+            .map_err(|_| CollectionError::not_found(format!("Snapshot {snapshot_name}")))?;
 
         if !absolute_snapshot_path.starts_with(absolute_snapshot_dir) {
-            return Err(CollectionError::NotFound {
-                what: format!("Snapshot {snapshot_name}"),
-            });
+            return Err(CollectionError::not_found(format!(
+                "Snapshot {snapshot_name}"
+            )));
         }
 
-        if !snapshot_path.exists() {
-            return Err(CollectionError::NotFound {
-                what: format!("Snapshot {snapshot_name}"),
-            });
+        if !absolute_snapshot_path.exists() {
+            return Err(CollectionError::not_found(format!(
+                "Snapshot {snapshot_name}"
+            )));
         }
-        Ok(snapshot_path)
+
+        Ok(absolute_snapshot_path)
     }
 
     pub async fn list_shard_snapshots(

programming_language: Rust
filename: types.rs
change_type: ModificationType.MODIFY
diff: @@ -959,6 +959,10 @@ impl CollectionError {
         CollectionError::BadInput { description }
     }
 
+    pub fn not_found(what: impl Into<String>) -> CollectionError {
+        CollectionError::NotFound { what: what.into() }
+    }
+
     pub fn bad_request(description: String) -> CollectionError {
         CollectionError::BadRequest { description }
     }

programming_language: Rust
filename: errors.rs
change_type: ModificationType.MODIFY
diff: @@ -56,6 +56,12 @@ impl StorageError {
         }
     }
 
+    pub fn not_found(description: impl Into<String>) -> StorageError {
+        StorageError::NotFound {
+            description: description.into(),
+        }
+    }
+
     pub fn checksum_mismatch(expected: impl Into<String>, actual: impl Into<String>) -> Self {
         StorageError::ChecksumMismatch {
             expected: expected.into(),

programming_language: Rust
filename: mod.rs
change_type: ModificationType.MODIFY
diff: @@ -23,17 +23,33 @@ pub struct SnapshotConfig {
     pub collections_aliases: HashMap<String, String>,
 }
 
+/// Get full file path for a full snapshot by name
+///
+/// This enforces the file to be inside the snapshots directory
 pub async fn get_full_snapshot_path(
     toc: &TableOfContent,
     snapshot_name: &str,
 ) -> Result<PathBuf, StorageError> {
-    let snapshot_path = Path::new(toc.snapshots_path()).join(snapshot_name);
-    if !snapshot_path.exists() {
-        return Err(StorageError::NotFound {
-            description: format!("Full storage snapshot {snapshot_name} not found"),
-        });
+    let snapshots_path = toc.snapshots_path();
+
+    let absolute_snapshot_dir = Path::new(snapshots_path)
+        .canonicalize()
+        .map_err(|_| StorageError::not_found(format!("Snapshot directory: {snapshots_path}")))?;
+
+    let absolute_snapshot_path = absolute_snapshot_dir
+        .join(snapshot_name)
+        .canonicalize()
+        .map_err(|_| StorageError::not_found(format!("Snapshot {snapshot_name}")))?;
+
+    if !absolute_snapshot_path.starts_with(absolute_snapshot_dir) {
+        return Err(StorageError::not_found(format!("Snapshot {snapshot_name}")));
     }
-    Ok(snapshot_path)
+
+    if !absolute_snapshot_path.exists() {
+        return Err(StorageError::not_found(format!("Snapshot {snapshot_name}")));
+    }
+
+    Ok(absolute_snapshot_path)
 }
 
 pub async fn do_delete_full_snapshot(

programming_language: Rust
filename: test_snapshot.py
change_type: ModificationType.MODIFY
diff: @@ -1,10 +1,14 @@
 from time import sleep
 import hashlib
+import os
 import pytest
+import requests
 
 from .helpers.collection_setup import basic_collection_setup, drop_collection
 from .helpers.helpers import request_with_validation
 
+QDRANT_HOST = os.environ.get("QDRANT_HOST", "localhost:6333")
+
 collection_name = 'test_collection_snapshot'
 
 
@@ -361,3 +365,31 @@ def test_snapshot_invalid_file_uri():
     )
     assert response.status_code == 400
     assert response.json()["status"]["error"] == "Bad request: Snapshot file \"/whatever.snapshot\" does not exist"
+
+
+def test_security():
+    # ensure we cannot do simple arbitrary path traversal
+    name = "/etc/passwd"
+    response = requests.get(
+        f"http://{QDRANT_HOST}/collections/{name}",
+        headers={"Content-Type": "application/json"},
+    )
+    assert not response.ok
+    assert response.status_code == 404
+
+    name = "../../../../../../../etc/passwd"
+    response = requests.get(
+        f"http://{QDRANT_HOST}/collections/{name}",
+        headers={"Content-Type": "application/json"},
+    )
+    assert not response.ok
+    assert response.status_code == 404
+
+    # ensure we cannot do arbitrary path traversal with encoded slashes
+    response = request_with_validation(
+        api='/snapshots/{snapshot_name}',
+        path_params={'snapshot_name': '..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Fetc%2Fpasswd'},
+        method="GET",
+    )
+    assert not response.ok
+    assert response.status_code == 404

programming_language: Python