From 3d06006f3663c577460d033f689cb96c9addee0a Mon Sep 17 00:00:00 2001 From: Hrushikesh Yadav <136978914+HrushiYadav@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:20:13 +0530 Subject: [PATCH] fix(valkey): escape special chars in FT.SEARCH tag filter values (#5750) --- mem0/vector_stores/valkey.py | 53 +++++++++++------------------- tests/vector_stores/test_valkey.py | 45 ++++++++++++++----------- 2 files changed, 44 insertions(+), 54 deletions(-) diff --git a/mem0/vector_stores/valkey.py b/mem0/vector_stores/valkey.py index 7ba333d19..0688a1288 100644 --- a/mem0/vector_stores/valkey.py +++ b/mem0/vector_stores/valkey.py @@ -41,7 +41,20 @@ class OutputData(BaseModel): payload: Dict +_VALKEY_TAG_SPECIAL = set(r',.<>{}[]"\':;!@#$%^&*()-+=~| ') + + class ValkeyDB(VectorStoreBase): + @staticmethod + def _escape_tag_value(value): + """Escape special characters in a Valkey FT.SEARCH tag filter value. + + Without escaping, characters like * (wildcard) or | (OR) alter query + semantics and can bypass tenant-isolation filters. + """ + s = str(value) + return "".join(f"\\{c}" if c in _VALKEY_TAG_SPECIAL else c for c in s) + def __init__( self, valkey_url: str, @@ -329,8 +342,8 @@ class ValkeyDB(VectorStoreBase): knn_part (str): The KNN part of the query. filters (dict, optional): Filters to apply to the search. Each key-value pair becomes a tag filter (@key:{value}). None values are ignored. - Values are used as-is (no validation) - wildcards, lists, etc. are - passed through literally to Valkey search. Multiple filters are + Values are escaped via _escape_tag_value() before interpolation + to prevent wildcard/operator injection. Multiple filters are combined with AND logic (space-separated). Returns: @@ -345,8 +358,8 @@ class ValkeyDB(VectorStoreBase): filter_parts = [] for key, value in filters.items(): if value is not None: - # Use the correct filter syntax for Valkey - filter_parts.append(f"@{key}:{{{value}}}") + escaped = self._escape_tag_value(value) + filter_parts.append(f"@{key}:{{{escaped}}}") # No valid filter parts if not filter_parts: @@ -750,34 +763,6 @@ class ValkeyDB(VectorStoreBase): logger.exception(f"Error resetting index {self.collection_name}: {e}") raise - def _build_list_query(self, filters=None): - """ - Build a query for listing vectors. - - Args: - filters (dict, optional): Filters to apply to the list. Each key-value pair - becomes a tag filter (@key:{value}). None values are ignored. - Values are used as-is (no validation) - wildcards, lists, etc. are - passed through literally to Valkey search. - - Returns: - str: The query string. Returns "*" if no valid filters provided. - """ - # Default query - q = "*" - - # Add filters if provided - if filters and any(value is not None for key, value in filters.items()): - filter_conditions = [] - for key, value in filters.items(): - if value is not None: - filter_conditions.append(f"@{key}:{{{value}}}") - - if filter_conditions: - q = " ".join(filter_conditions) - - return q - def list(self, filters: dict = None, top_k: int = None) -> list: """ List all recent created memories from the vector store. @@ -785,8 +770,8 @@ class ValkeyDB(VectorStoreBase): Args: filters (dict, optional): Filters to apply to the list. Each key-value pair becomes a tag filter (@key:{value}). None values are ignored. - Values are used as-is without validation - wildcards, special characters, - lists, etc. are passed through literally to Valkey search. + Values are escaped via _escape_tag_value() before interpolation + to prevent wildcard/operator injection. Multiple filters are combined with AND logic. top_k (int, optional): Maximum number of results to return. Defaults to 1000 if not specified. diff --git a/tests/vector_stores/test_valkey.py b/tests/vector_stores/test_valkey.py index 715b1bb2a..769f511d4 100644 --- a/tests/vector_stores/test_valkey.py +++ b/tests/vector_stores/test_valkey.py @@ -490,26 +490,6 @@ def test_reset(valkey_db, mock_valkey_client): assert result is True -def test_build_list_query(valkey_db): - """Test building a list query with and without filters.""" - # Test without filters - query = valkey_db._build_list_query(None) - assert query == "*" - - # Test with empty filters - query = valkey_db._build_list_query({}) - assert query == "*" - - # Test with filters - query = valkey_db._build_list_query({"user_id": "test_user"}) - assert query == "@user_id:{test_user}" - - # Test with multiple filters - query = valkey_db._build_list_query({"user_id": "test_user", "agent_id": "test_agent"}) - assert "@user_id:{test_user}" in query - assert "@agent_id:{test_agent}" in query - - def test_process_document_fields(valkey_db): """Test processing document fields from hash results.""" # Create a mock result with all fields @@ -1061,3 +1041,28 @@ def test_build_index_schema_indexes_memory_as_text(valkey_db): ) # And it must not be declared as TAG. assert ["memory", "TAG"] != cmd[memory_idx : memory_idx + 2] + + +def test_escape_tag_value_wildcards(valkey_db): + """Wildcard characters in filter values must be escaped to prevent query injection.""" + assert "\\*" in valkey_db._escape_tag_value("*") + assert "\\|" in valkey_db._escape_tag_value("a|b") + + +def test_build_search_query_escapes_filter_values(valkey_db): + """_build_search_query must escape special chars in filter values.""" + knn_part = "[KNN 5 @embedding $vec_param AS vector_score]" + query = valkey_db._build_search_query(knn_part, {"user_id": "*"}) + assert "\\*" in query + assert "@user_id:{\\*}" in query + + +def test_escape_tag_value_normal_strings(valkey_db): + """Normal alphanumeric filter values must pass through unchanged.""" + assert valkey_db._escape_tag_value("alice") == "alice" + assert valkey_db._escape_tag_value("user123") == "user123" + + +def test_escape_tag_value_hyphenated_user_id(valkey_db): + """Hyphenated user IDs must have the hyphen escaped for exact-match.""" + assert valkey_db._escape_tag_value("user-123") == r"user\-123"