From ff63de3d3d45df8435cdd16f55009d5a29e60822 Mon Sep 17 00:00:00 2001 From: allen0099 Date: Tue, 29 Sep 2026 12:28:07 +0000 Subject: [PATCH] fix(state): treat non-object and oversized-integer JSON as malformed state _decode_state caught only ValueError around StateData(**...) and only JSONDecodeError around json.loads, so a stored state that was JSON but not an object escaped as TypeError, and one holding an integer past the interpreter's digit limit escaped as ValueError. validate_state, get_state_metadata and consume_state now treat both as malformed data. Fixes #368 --- changelog.d/368.fixed.md | 6 +++ fastapi_cachex/state/manager.py | 14 +++++-- tests/state/test_manager.py | 72 +++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 changelog.d/368.fixed.md diff --git a/changelog.d/368.fixed.md b/changelog.d/368.fixed.md new file mode 100644 index 0000000..db3041d --- /dev/null +++ b/changelog.d/368.fixed.md @@ -0,0 +1,6 @@ +**`StateManager` treats more kinds of malformed stored state as malformed.** +A stored value that is JSON but not an object, such as `[1, 2]` or `"x"`, used +to escape as `TypeError`, and one holding an integer longer than Python's +digit limit (4300 by default) as `ValueError`. Now `consume_state()` raises +`StateDataError`, `validate_state()` returns `False` and +`get_state_metadata()` returns `None`, as documented for malformed data. diff --git a/fastapi_cachex/state/manager.py b/fastapi_cachex/state/manager.py index 6b4b40a..0c6cabe 100644 --- a/fastapi_cachex/state/manager.py +++ b/fastapi_cachex/state/manager.py @@ -107,8 +107,8 @@ def _decode_state(self, cached: CacheEntry) -> StateData: StateData instance Raises: - StateDataError: If the content is not UTF-8 text, not JSON, or does - not fit the StateData model + StateDataError: If the content is not UTF-8 text, not a JSON object, + or does not fit the StateData model """ try: json_content = cached.content.decode("utf-8") @@ -116,12 +116,18 @@ def _decode_state(self, cached: CacheEntry) -> StateData: msg = "Unexpected state data format" raise StateDataError(msg) from e + # ValueError covers JSONDecodeError and an integer longer than + # sys.int_info.default_max_str_digits. try: - state_dict: dict[str, Any] = json.loads(json_content) - except json.JSONDecodeError as e: + state_dict: object = json.loads(json_content) + except ValueError as e: msg = f"Failed to parse state data: {e}" raise StateDataError(msg) from e + if not isinstance(state_dict, dict): + msg = f"Invalid state data structure: expected a JSON object, got {type(state_dict).__name__}" + raise StateDataError(msg) + try: return StateData(**state_dict) except ValueError as e: diff --git a/tests/state/test_manager.py b/tests/state/test_manager.py index ce8a1b0..c77283e 100644 --- a/tests/state/test_manager.py +++ b/tests/state/test_manager.py @@ -555,6 +555,78 @@ async def test_get_metadata_with_non_string_content( assert retrieved is None +NON_OBJECT_JSON = [ + pytest.param(b"[1, 2]", id="array"), + pytest.param(b'"x"', id="string"), + pytest.param(b"1", id="number"), + pytest.param(b"null", id="null"), +] + + +async def _store_raw_state( + state_manager: StateManager, state: str, content: bytes +) -> None: + entry = CacheEntry(fingerprint=hashlib.sha256(content).hexdigest(), content=content) + await state_manager.backend.set( + f"{state_manager.key_prefix}{state}", entry, ttl=600 + ) + + +@pytest.mark.parametrize( + "content", + [ + pytest.param(b"1" * 5000, id="top-level"), + pytest.param( + b'{"state": "s", "metadata": {"n": ' + b"1" * 5000 + b"}}", id="nested" + ), + ], +) +async def test_oversized_json_integer_is_malformed( + state_manager: StateManager, content: bytes +) -> None: + """An integer past the interpreter's digit limit is malformed data, not a ValueError.""" + await _store_raw_state(state_manager, "bad_state", content) + + assert await state_manager.validate_state("bad_state") is False + assert await state_manager.get_state_metadata("bad_state") is None + with pytest.raises(StateDataError, match="Failed to parse state data"): + await state_manager.consume_state("bad_state") + + +@pytest.mark.parametrize("content", NON_OBJECT_JSON) +async def test_consume_state_with_non_object_json( + state_manager: StateManager, content: bytes +) -> None: + """consume_state() raises StateDataError (not TypeError) for JSON that is not an object.""" + await _store_raw_state(state_manager, "bad_state", content) + + with pytest.raises(StateDataError, match="expected a JSON object"): + await state_manager.consume_state("bad_state") + assert ( + await state_manager.backend.get(f"{state_manager.key_prefix}bad_state") is None + ) + + +@pytest.mark.parametrize("content", NON_OBJECT_JSON) +async def test_validate_state_with_non_object_json( + state_manager: StateManager, content: bytes +) -> None: + """validate_state() returns False for JSON that is not an object.""" + await _store_raw_state(state_manager, "bad_state", content) + + assert await state_manager.validate_state("bad_state") is False + + +@pytest.mark.parametrize("content", NON_OBJECT_JSON) +async def test_get_metadata_with_non_object_json( + state_manager: StateManager, content: bytes +) -> None: + """get_state_metadata() returns None for JSON that is not an object.""" + await _store_raw_state(state_manager, "bad_state", content) + + assert await state_manager.get_state_metadata("bad_state") is None + + async def test_get_metadata_with_non_dict_metadata(state_manager: StateManager) -> None: """Test retrieving metadata when metadata is not a dict.""" state = "test_state"