From 344ab653512b042b1fe7ddda3b68c1a634577a57 Mon Sep 17 00:00:00 2001 From: Eugene Yurtsev Date: Mon, 8 Dec 2025 21:24:00 -0500 Subject: [PATCH] checkpoint sqlite --- .../langgraph/checkpoint/sqlite/utils.py | 19 ++++++ libs/checkpoint-sqlite/tests/test_sqlite.py | 61 +++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/utils.py b/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/utils.py index a3b8b7cec..9f9e6131b 100644 --- a/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/utils.py +++ b/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/utils.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import re from collections.abc import Sequence from typing import Any @@ -8,6 +9,23 @@ from langchain_core.runnables import RunnableConfig from langgraph.checkpoint.base import get_checkpoint_id +def _validate_filter_key(key: str) -> None: + """Validate that a filter key is safe for use in SQL queries. + + Args: + key: The filter key to validate + + Raises: + ValueError: If the key contains invalid characters that could enable SQL injection + """ + # Allow alphanumeric characters, underscores, dots, and hyphens + # This covers typical JSON property names while preventing SQL injection + if not re.match(r"^[a-zA-Z0-9_.-]+$", key): + raise ValueError( + f"Invalid filter key: '{key}'. Filter keys must contain only alphanumeric characters, underscores, dots, and hyphens." + ) + + def _metadata_predicate( metadata_filter: dict[str, Any], ) -> tuple[Sequence[str], Sequence[Any]]: @@ -43,6 +61,7 @@ def _metadata_predicate( # process metadata query for query_key, query_value in metadata_filter.items(): + _validate_filter_key(query_key) operator, param_value = _where_value(query_value) predicates.append( f"json_extract(CAST(metadata AS TEXT), '$.{query_key}') {operator}" diff --git a/libs/checkpoint-sqlite/tests/test_sqlite.py b/libs/checkpoint-sqlite/tests/test_sqlite.py index 062bde23c..a138b4c3d 100644 --- a/libs/checkpoint-sqlite/tests/test_sqlite.py +++ b/libs/checkpoint-sqlite/tests/test_sqlite.py @@ -182,3 +182,64 @@ class TestSqliteSaver: with pytest.raises(NotImplementedError, match="AsyncSqliteSaver"): async for _ in saver.alist(self.config_1): pass + + def test_metadata_predicate_sql_injection_prevention(self) -> None: + """Test that _metadata_predicate rejects malicious filter keys.""" + # Test various SQL injection payloads + malicious_keys = [ + "x') OR '1'='1", # Boolean-based injection + "x') OR 1=1 --", # Comment-based injection + "x') UNION SELECT 1,2,3,4,5,6,7 --", # UNION-based injection + "access') = 'public' OR '1'='1' OR json_extract(value, '$.", # Complex injection + "'; DROP TABLE checkpoints; --", # Destructive injection + ] + + for malicious_key in malicious_keys: + with pytest.raises(ValueError, match="Invalid filter key"): + _metadata_predicate({malicious_key: "dummy"}) + + def test_checkpoint_search_sql_injection_prevention(self) -> None: + """Test that SQL injection via malicious filter keys is prevented in checkpoint search.""" + with SqliteSaver.from_conn_string(":memory:") as saver: + # Setup: Create checkpoints with different metadata + config_public: RunnableConfig = { + "configurable": { + "thread_id": "thread-public", + "checkpoint_ns": "", + } + } + config_private: RunnableConfig = { + "configurable": { + "thread_id": "thread-private", + "checkpoint_ns": "", + } + } + + checkpoint_public = empty_checkpoint() + checkpoint_private = empty_checkpoint() + + metadata_public: CheckpointMetadata = { + "access": "public", + "data": "public information", + } + metadata_private: CheckpointMetadata = { + "access": "private", + "data": "secret information", + "password": "secret123", + } + + saver.put(config_public, checkpoint_public, metadata_public, {}) + saver.put(config_private, checkpoint_private, metadata_private, {}) + + # Normal query - should return only public checkpoint + normal_results = list(saver.list(None, filter={"access": "public"})) + assert len(normal_results) == 1 + assert normal_results[0].metadata["access"] == "public" + + # SQL injection attempt should raise ValueError + malicious_key = ( + "access') = 'public' OR '1'='1' OR json_extract(metadata, '$." + ) + + with pytest.raises(ValueError, match="Invalid filter key"): + list(saver.list(None, filter={malicious_key: "dummy"}))