Skip to content

Commit 6101f59

Browse files
committed
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
1 parent d3e6377 commit 6101f59

3 files changed

Lines changed: 88 additions & 4 deletions

File tree

‎changelog.d/368.fixed.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
**`StateManager` treats more kinds of malformed stored state as malformed.**
2+
A stored value that is JSON but not an object, such as `[1, 2]` or `"x"`, used
3+
to escape as `TypeError`, and one holding an integer longer than Python's
4+
digit limit (4300 by default) as `ValueError`. Now `consume_state()` raises
5+
`StateDataError`, `validate_state()` returns `False` and
6+
`get_state_metadata()` returns `None`, as documented for malformed data.

‎fastapi_cachex/state/manager.py‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,21 +107,27 @@ def _decode_state(self, cached: CacheEntry) -> StateData:
107107
StateData instance
108108
109109
Raises:
110-
StateDataError: If the content is not UTF-8 text, not JSON, or does
111-
not fit the StateData model
110+
StateDataError: If the content is not UTF-8 text, not a JSON object,
111+
or does not fit the StateData model
112112
"""
113113
try:
114114
json_content = cached.content.decode("utf-8")
115115
except (AttributeError, UnicodeDecodeError) as e:
116116
msg = "Unexpected state data format"
117117
raise StateDataError(msg) from e
118118

119+
# ValueError covers JSONDecodeError and an integer longer than
120+
# sys.int_info.default_max_str_digits.
119121
try:
120-
state_dict: dict[str, Any] = json.loads(json_content)
121-
except json.JSONDecodeError as e:
122+
state_dict: object = json.loads(json_content)
123+
except ValueError as e:
122124
msg = f"Failed to parse state data: {e}"
123125
raise StateDataError(msg) from e
124126

127+
if not isinstance(state_dict, dict):
128+
msg = f"Invalid state data structure: expected a JSON object, got {type(state_dict).__name__}"
129+
raise StateDataError(msg)
130+
125131
try:
126132
return StateData(**state_dict)
127133
except ValueError as e:

‎tests/state/test_manager.py‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,78 @@ async def test_get_metadata_with_non_string_content(
555555
assert retrieved is None
556556

557557

558+
NON_OBJECT_JSON = [
559+
pytest.param(b"[1, 2]", id="array"),
560+
pytest.param(b'"x"', id="string"),
561+
pytest.param(b"1", id="number"),
562+
pytest.param(b"null", id="null"),
563+
]
564+
565+
566+
async def _store_raw_state(
567+
state_manager: StateManager, state: str, content: bytes
568+
) -> None:
569+
entry = CacheEntry(fingerprint=hashlib.sha256(content).hexdigest(), content=content)
570+
await state_manager.backend.set(
571+
f"{state_manager.key_prefix}{state}", entry, ttl=600
572+
)
573+
574+
575+
@pytest.mark.parametrize(
576+
"content",
577+
[
578+
pytest.param(b"1" * 5000, id="top-level"),
579+
pytest.param(
580+
b'{"state": "s", "metadata": {"n": ' + b"1" * 5000 + b"}}", id="nested"
581+
),
582+
],
583+
)
584+
async def test_oversized_json_integer_is_malformed(
585+
state_manager: StateManager, content: bytes
586+
) -> None:
587+
"""An integer past the interpreter's digit limit is malformed data, not a ValueError."""
588+
await _store_raw_state(state_manager, "bad_state", content)
589+
590+
assert await state_manager.validate_state("bad_state") is False
591+
assert await state_manager.get_state_metadata("bad_state") is None
592+
with pytest.raises(StateDataError, match="Failed to parse state data"):
593+
await state_manager.consume_state("bad_state")
594+
595+
596+
@pytest.mark.parametrize("content", NON_OBJECT_JSON)
597+
async def test_consume_state_with_non_object_json(
598+
state_manager: StateManager, content: bytes
599+
) -> None:
600+
"""consume_state() raises StateDataError (not TypeError) for JSON that is not an object."""
601+
await _store_raw_state(state_manager, "bad_state", content)
602+
603+
with pytest.raises(StateDataError, match="expected a JSON object"):
604+
await state_manager.consume_state("bad_state")
605+
assert (
606+
await state_manager.backend.get(f"{state_manager.key_prefix}bad_state") is None
607+
)
608+
609+
610+
@pytest.mark.parametrize("content", NON_OBJECT_JSON)
611+
async def test_validate_state_with_non_object_json(
612+
state_manager: StateManager, content: bytes
613+
) -> None:
614+
"""validate_state() returns False for JSON that is not an object."""
615+
await _store_raw_state(state_manager, "bad_state", content)
616+
617+
assert await state_manager.validate_state("bad_state") is False
618+
619+
620+
@pytest.mark.parametrize("content", NON_OBJECT_JSON)
621+
async def test_get_metadata_with_non_object_json(
622+
state_manager: StateManager, content: bytes
623+
) -> None:
624+
"""get_state_metadata() returns None for JSON that is not an object."""
625+
await _store_raw_state(state_manager, "bad_state", content)
626+
627+
assert await state_manager.get_state_metadata("bad_state") is None
628+
629+
558630
async def test_get_metadata_with_non_dict_metadata(state_manager: StateManager) -> None:
559631
"""Test retrieving metadata when metadata is not a dict."""
560632
state = "test_state"

0 commit comments

Comments
 (0)