fix: prevent arbitrary code execution via pickle in FAISS vector store (#4833)

This commit is contained in:
Kartik
2026-04-15 00:02:07 +05:30
committed by GitHub
parent 5d40592e42
commit a5a688295e
2 changed files with 523 additions and 20 deletions
+155 -17
View File
@@ -1,10 +1,11 @@
import json
import logging
import os
import pickle
import uuid
import warnings
from pathlib import Path
from typing import Dict, List, Optional
from typing import Any, Dict, List, Optional
import numpy as np
from pydantic import BaseModel
@@ -13,7 +14,7 @@ try:
# Suppress SWIG deprecation warnings from FAISS
warnings.filterwarnings("ignore", category=DeprecationWarning, message=".*SwigPy.*")
warnings.filterwarnings("ignore", category=DeprecationWarning, message=".*swigvarlink.*")
logging.getLogger("faiss").setLevel(logging.WARNING)
logging.getLogger("faiss.loader").setLevel(logging.WARNING)
@@ -30,6 +31,93 @@ from mem0.vector_stores.base import VectorStoreBase
logger = logging.getLogger(__name__)
class SafeUnpickler(pickle.Unpickler):
"""
Restricted unpickler that only allows safe built-in types.
This prevents arbitrary code execution via pickle deserialization by only
allowing a whitelist of safe types (dict, list, str, int, float, bool, tuple, None).
"""
# Only allow builtins module
SAFE_MODULES = frozenset({"builtins", "__builtin__"})
# Only allow safe basic types
SAFE_NAMES = frozenset({"dict", "list", "str", "int", "float", "bool", "tuple", "set", "frozenset", "NoneType"})
def find_class(self, module: str, name: str) -> Any:
"""Override find_class to only allow safe types."""
if module in self.SAFE_MODULES and name in self.SAFE_NAMES:
import builtins
if hasattr(builtins, name):
return getattr(builtins, name)
# NoneType special case
if name == "NoneType":
return type(None)
raise pickle.UnpicklingError(
f"Unsafe pickle: attempted to load '{module}.{name}'. "
f"Only basic Python types are allowed for security reasons."
)
def _safe_pickle_load(file_path: str) -> Any:
"""
Safely load a pickle file using restricted unpickler.
Args:
file_path: Path to the pickle file.
Returns:
The deserialized object (only basic Python types allowed).
Raises:
pickle.UnpicklingError: If the pickle contains unsafe types.
"""
with open(file_path, "rb") as f:
return SafeUnpickler(f).load()
def _validate_docstore_structure(data: Any) -> tuple:
"""
Validate that loaded data has the expected structure.
Args:
data: The loaded data to validate.
Returns:
Tuple of (docstore, index_to_id) if valid.
Raises:
ValueError: If the data structure is invalid.
"""
if not isinstance(data, tuple) or len(data) != 2:
raise ValueError("Invalid docstore format: expected tuple of (docstore, index_to_id)")
docstore, index_to_id = data
if not isinstance(docstore, dict):
raise ValueError("Invalid docstore format: docstore must be a dict")
if not isinstance(index_to_id, dict):
raise ValueError("Invalid docstore format: index_to_id must be a dict")
# Validate docstore entries
for key, value in docstore.items():
if not isinstance(key, str):
raise ValueError(f"Invalid docstore key type: {type(key)}, expected str")
if not isinstance(value, dict):
raise ValueError(f"Invalid docstore value type: {type(value)}, expected dict")
# Validate index_to_id entries
for key, value in index_to_id.items():
if not isinstance(key, int):
raise ValueError(f"Invalid index_to_id key type: {type(key)}, expected int")
if not isinstance(value, str):
raise ValueError(f"Invalid index_to_id value type: {type(value)}, expected str")
return docstore, index_to_id
class OutputData(BaseModel):
id: Optional[str] # memory id
score: Optional[float] # distance
@@ -73,9 +161,13 @@ class FAISS(VectorStoreBase):
# Try to load existing index if available
index_path = f"{self.path}/{collection_name}.faiss"
docstore_path = f"{self.path}/{collection_name}.pkl"
if os.path.exists(index_path) and os.path.exists(docstore_path):
self._load(index_path, docstore_path)
json_docstore_path = f"{self.path}/{collection_name}.json"
pkl_docstore_path = f"{self.path}/{collection_name}.pkl"
# Check for index file and either JSON (preferred) or legacy pickle docstore
if os.path.exists(index_path) and (os.path.exists(json_docstore_path) or os.path.exists(pkl_docstore_path)):
# _load will prefer JSON over pickle and auto-migrate
self._load(index_path, pkl_docstore_path)
else:
self.create_col(collection_name)
@@ -83,34 +175,76 @@ class FAISS(VectorStoreBase):
"""
Load FAISS index and docstore from disk.
Supports both JSON (preferred) and legacy pickle formats. Pickle files are loaded
using a restricted unpickler that only allows basic Python types to prevent
arbitrary code execution (CVE mitigation).
Args:
index_path (str): Path to FAISS index file.
docstore_path (str): Path to docstore pickle file.
docstore_path (str): Path to docstore file (.json or legacy .pkl).
"""
try:
self.index = faiss.read_index(index_path)
with open(docstore_path, "rb") as f:
self.docstore, self.index_to_id = pickle.load(f)
logger.info(f"Loaded FAISS index from {index_path} with {self.index.ntotal} vectors")
# Determine docstore format - prefer JSON over pickle
json_docstore_path = docstore_path.replace(".pkl", ".json")
if os.path.exists(json_docstore_path):
# Load from JSON (safe, preferred format)
with open(json_docstore_path, "r", encoding="utf-8") as f:
data = json.load(f)
self.docstore = data.get("docstore", {})
# JSON keys are always strings, convert back to int
self.index_to_id = {int(k): v for k, v in data.get("index_to_id", {}).items()}
logger.info(f"Loaded FAISS index from {index_path} with {self.index.ntotal} vectors (JSON format)")
elif os.path.exists(docstore_path):
# Load from legacy pickle using safe unpickler
# This prevents arbitrary code execution from malicious pickle files
logger.warning(
f"Loading legacy pickle docstore from {docstore_path}. "
f"Consider migrating to JSON format for better security."
)
data = _safe_pickle_load(docstore_path)
self.docstore, self.index_to_id = _validate_docstore_structure(data)
logger.info(f"Loaded FAISS index from {index_path} with {self.index.ntotal} vectors (pickle format)")
# Auto-migrate to JSON format
self._save()
logger.info(f"Migrated docstore to JSON format: {json_docstore_path}")
else:
raise FileNotFoundError(f"No docstore found at {docstore_path} or {json_docstore_path}")
except pickle.UnpicklingError as e:
logger.error(f"Security error loading FAISS docstore: {e}")
raise ValueError(f"Failed to load FAISS docstore: potentially malicious pickle file. {e}") from e
except Exception as e:
logger.warning(f"Failed to load FAISS index: {e}")
self.docstore = {}
self.index_to_id = {}
def _save(self):
"""Save FAISS index and docstore to disk."""
"""Save FAISS index and docstore to disk using JSON format (secure)."""
if not self.path or not self.index:
return
try:
os.makedirs(self.path, exist_ok=True)
index_path = f"{self.path}/{self.collection_name}.faiss"
docstore_path = f"{self.path}/{self.collection_name}.pkl"
json_docstore_path = f"{self.path}/{self.collection_name}.json"
faiss.write_index(self.index, index_path)
with open(docstore_path, "wb") as f:
pickle.dump((self.docstore, self.index_to_id), f)
# Save docstore as JSON (safe format, no code execution risk)
# JSON keys must be strings, so convert int keys to str
data = {
"docstore": self.docstore,
"index_to_id": {str(k): v for k, v in self.index_to_id.items()},
}
with open(json_docstore_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
except Exception as e:
logger.warning(f"Failed to save FAISS index: {e}")
@@ -417,12 +551,16 @@ class FAISS(VectorStoreBase):
if self.path:
try:
index_path = f"{self.path}/{self.collection_name}.faiss"
docstore_path = f"{self.path}/{self.collection_name}.pkl"
json_docstore_path = f"{self.path}/{self.collection_name}.json"
pkl_docstore_path = f"{self.path}/{self.collection_name}.pkl"
if os.path.exists(index_path):
os.remove(index_path)
if os.path.exists(docstore_path):
os.remove(docstore_path)
if os.path.exists(json_docstore_path):
os.remove(json_docstore_path)
# Also clean up legacy pickle files if they exist
if os.path.exists(pkl_docstore_path):
os.remove(pkl_docstore_path)
logger.info(f"Deleted collection {self.collection_name}")
except Exception as e: