Repository: jsonpickle/jsonpickle
Pull request #546 (2024-12-20 00:08:41)

unpickler: increase robustness to invalid py/id data
Detect TypeError when py/id values are resolved so that we can return None when the payload contains invalid data.


Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 8171005..ff917d4 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -1,3 +1,8 @@
+Upcoming
+========
+    * The unpickler is now more resilient to malformed "py/id" and "py/repr" data.
+      (+546)
+
 v4.0.1
 ======
     * The unpickler is now more resilient to malformed "py/reduce", "py/set",
diff --git a/jsonpickle/unpickler.py b/jsonpickle/unpickler.py
index 3ea9ade..dee0894 100644
--- a/jsonpickle/unpickler.py
+++ b/jsonpickle/unpickler.py
@@ -162,7 +162,10 @@ class _IDProxy(_Proxy):
         self._objs = objs
 
     def get(self):
-        return self._objs[self._index]
+        try:
+            return self._objs[self._index]
+        except IndexError:
+            return None
 
 
 def _obj_setattr(obj, attr, proxy):
@@ -310,8 +313,15 @@ def _loadmodule(module_str):
 
     """
     module, identifier = module_str.split('/')
-    result = __import__(module)
-    for name in identifier.split('.')[1:]:
+    try:
+        result = __import__(module)
+    except ImportError:
+        return None
+    identifier_parts = identifier.split('.')
+    first_identifier = identifier_parts[0]
+    if first_identifier != module and not module.startswith(f'{first_identifier}.'):
+        return None
+    for name in identifier_parts[1:]:
         try:
             result = getattr(result, name)
         except AttributeError:
@@ -591,6 +601,8 @@ class Unpickler:
             return self._objs[idx]
         except IndexError:
             return _IDProxy(self._objs, idx)
+        except TypeError:
+            return None
 
     def _restore_type(self, obj):
         typeref = loadclass(obj[tags.TYPE], classes=self._classes)
@@ -917,7 +929,9 @@ class Unpickler:
                 else:
                     str_k = k
                 self._namestack.append(str_k)
-                data[k] = self._restore(v)
+                data[k] = result = self._restore(v)
+                if isinstance(result, _Proxy):
+                    self._proxies.append((data, k, result, _obj_setvalue))
                 self._namestack.pop()
         return data
 


Test patch:
diff --git a/tests/jsonpickle_test.py b/tests/jsonpickle_test.py
index d438827..0b240b6 100644
--- a/tests/jsonpickle_test.py
+++ b/tests/jsonpickle_test.py
@@ -346,6 +346,13 @@ def test_reduce_with_invalid_data(value, unpickler):
     assert result == []
 
 
+@pytest.mark.parametrize('value', ['', 'x', 1, True, [], {}])
+def test_restore_id_with_invalid_data(value, unpickler):
+    """Invalid serialized ID data results in None"""
+    result = unpickler.restore({'ref': {tags.ID: value}})
+    assert result['ref'] is None
+
+
 def test_dict(pickler, unpickler):
     """Our custom keys are preserved when user dicts contain them"""
     dict_a = {'key1': 1.0, 'key2': 20, 'key3': 'thirty', tags.JSON_KEY + '6': 6}
@@ -606,6 +613,21 @@ def test_restore_legacy_builtins():
     assert cls is int
 
 
+@pytest.mark.parametrize(
+    'value,expect',
+    [
+        ('module_does_not_exist/ignored', None),
+        ('builtins/int', None),
+        ('builtins/invalid.int', None),
+        ('builtins/builtinsx.int', None),
+    ],
+)
+def test_restore_invalid_repr(value, expect, unpickler):
+    """Test restoring invalid repr tags"""
+    result = unpickler.restore({tags.REPR: value})
+    assert result is expect
+
+
 def test_unpickler_on_missing():
     """Emit warnings when decoding objects whose classes are missing"""
     encoded = jsonpickle.encode(Outer.Middle.Inner())