From d236883e2d304c94a887ce6de6d6b721e7213164 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:21:56 +0800 Subject: [PATCH] fix(pairing): reject malformed store entries --- nanobot/pairing/store.py | 14 +++++++++++++- tests/pairing/test_store.py | 23 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/nanobot/pairing/store.py b/nanobot/pairing/store.py index c26f4e0b..490369cb 100644 --- a/nanobot/pairing/store.py +++ b/nanobot/pairing/store.py @@ -43,6 +43,9 @@ def _load() -> dict[str, Any]: except (json.JSONDecodeError, OSError): logger.warning("Corrupted pairing store, resetting") return {"approved": {}, "pending": {}} + if not isinstance(data, dict): + logger.warning("Corrupted pairing store, resetting") + return {"approved": {}, "pending": {}} # JSON stores may contain null maps after partial edits; treat like {}. approved = data.get("approved") or {} @@ -89,7 +92,15 @@ def _gc_pending(data: dict[str, Any]) -> None: expired = [ code for code, info in pending.items() - if not isinstance(info, dict) or info.get("expires_at", 0) < now + if ( + not isinstance(info, dict) + or not isinstance(info.get("channel"), str) + or not info.get("channel") + or info.get("sender_id") is None + or isinstance(info.get("expires_at"), bool) + or not isinstance(info.get("expires_at"), (int, float)) + or info["expires_at"] < now + ) ] for code in expired: del pending[code] @@ -220,6 +231,7 @@ def clear_channel(channel: str) -> dict[str, int]: """Remove approved senders and pending requests for *channel*.""" with _LOCK: data = _load() + _gc_pending(data) approved: dict[str, set[str]] = data.get("approved", {}) approved_users = approved.pop(channel, set()) diff --git a/tests/pairing/test_store.py b/tests/pairing/test_store.py index 766657f6..c4b4758a 100644 --- a/tests/pairing/test_store.py +++ b/tests/pairing/test_store.py @@ -272,6 +272,15 @@ def test_load_treats_null_approved_and_pending_maps_as_empty(tmp_path, monkeypat assert store.get_approved("telegram") == [] +@pytest.mark.parametrize("payload", ["null", "[]", "true"]) +def test_load_treats_non_object_store_as_empty(tmp_path, monkeypatch, payload): + path = tmp_path / "pairing.json" + path.write_text(payload, encoding="utf-8") + monkeypatch.setattr(store, "_store_path", lambda: path) + assert store.list_pending() == [] + assert store.is_approved("telegram", "123") is False + + def test_list_pending_skips_null_pending_entries(tmp_path, monkeypatch): """Null pending entry values must be dropped instead of crashing list_pending.""" path = tmp_path / "pairing.json" @@ -281,3 +290,17 @@ def test_list_pending_skips_null_pending_entries(tmp_path, monkeypatch): ) monkeypatch.setattr(store, "_store_path", lambda: path) assert store.list_pending() == [] + assert store.clear_channel("telegram") == {"approved": 0, "pending": 0} + + +def test_pending_gc_drops_malformed_entries(tmp_path, monkeypatch): + path = tmp_path / "pairing.json" + path.write_text( + '{"approved": {}, "pending": {' + '"bad-expiry": {"channel": "telegram", "sender_id": "123", "expires_at": null},' + '"missing-sender": {"channel": "telegram", "expires_at": 9999999999}' + "}}", + encoding="utf-8", + ) + monkeypatch.setattr(store, "_store_path", lambda: path) + assert store.list_pending() == []