Skip to content

Commit 20629ea

Browse files
authored
Merge pull request #585 from praegt/fix/restrict-pickle-deserialization
Restrict pickle class loading for .localstate deserialization
2 parents 65ade30 + a983edd commit 20629ea

4 files changed

Lines changed: 54 additions & 10 deletions

File tree

CHANGES.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
Changelog
22
=========
3+
--------------------
4+
3.27.2 (2026-05-13)
5+
--------------------
6+
- Restrict pickle class loading for ``.localstate`` deserialization in ``eb local`` commands
7+
38
--------------------
49
3.27.1 (2026-03-25)
510
--------------------

ebcli/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@
1111
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
1212
# ANY KIND, either express or implied. See the License for the specific
1313
# language governing permissions and limitations under the License.
14-
__version__ = '3.27.1'
14+
__version__ = '3.27.2'

ebcli/operations/localops.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,30 @@
1010
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
1111
# ANY KIND, either express or implied. See the License for the specific
1212
# language governing permissions and limitations under the License.
13-
from botocore.compat import six
13+
import io as _io
14+
import pickle
1415

1516
from ebcli.containers.envvarcollector import EnvvarCollector
1617
from ebcli.containers.pathconfig import PathConfig
1718
from ebcli.core import fileoperations, io
1819
from ebcli.lib import utils
1920
from ebcli.operations import commonops, envvarops
2021
from ebcli.resources.strings import strings
21-
cPickle = six.moves.cPickle
22+
23+
24+
class _SafeUnpickler(pickle.Unpickler):
25+
"""Only allow deserializing LocalState and EnvvarCollector."""
26+
_ALLOWED = {
27+
('ebcli.operations.localops', 'LocalState'),
28+
('ebcli.containers.envvarcollector', 'EnvvarCollector'),
29+
('__builtin__', 'set'),
30+
('builtins', 'set'),
31+
}
32+
33+
def find_class(self, module, name):
34+
if (module, name) in self._ALLOWED:
35+
return super().find_class(module, name)
36+
raise pickle.UnpicklingError(f"Forbidden: {module}.{name}")
2237

2338

2439
class LocalState(object):
@@ -30,15 +45,15 @@ def __init__(self, envvarcollector):
3045
self.envvarcollector = envvarcollector
3146

3247
def dumps(self, path):
33-
fileoperations.write_to_data_file(data=cPickle.dumps(self, protocol=2),
48+
fileoperations.write_to_data_file(data=pickle.dumps(self, protocol=2),
3449
location=path)
3550

3651
@classmethod
3752
def loads(cls, path):
3853
try:
3954
data = fileoperations.read_from_data_file(path)
40-
return cPickle.loads(data)
41-
except IOError:
55+
return _SafeUnpickler(_io.BytesIO(data)).load()
56+
except (IOError, pickle.UnpicklingError):
4257
return cls(EnvvarCollector())
4358

4459
@classmethod

tests/unit/operations/test_localops.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,18 +66,42 @@ def setUp(self):
6666
def test_constructor(self):
6767
self.assertEqual(self.envvarcollector, self.localstate.envvarcollector)
6868

69-
@patch('ebcli.operations.localops.cPickle')
69+
@patch('ebcli.operations.localops._SafeUnpickler')
7070
@patch('ebcli.operations.localops.fileoperations')
71-
def test_loads(self, fileoperations, cPickle):
71+
def test_loads(self, fileoperations, safe_unpickler):
7272
fileoperations.read_from_data_file.return_value = b'foo'
73-
cPickle.loads.return_value = self.localstate
73+
safe_unpickler.return_value.load.return_value = self.localstate
7474

7575
self.assertEqual(self.localstate, LocalState.loads(LOCAL_STATE_PATH))
76-
cPickle.loads.assert_called_once_with(b'foo')
7776

7877
@patch('ebcli.operations.localops.LocalState.loads')
7978
def test_get_envvarcollector(self, loads):
8079
loads.return_value = self.localstate
8180

8281
self.assertEqual(self.envvarcollector,
8382
LocalState.get_envvarcollector(LOCAL_STATE_PATH))
83+
84+
@patch('ebcli.operations.localops.fileoperations')
85+
def test_loads_rejects_unlisted_class(self, fileoperations):
86+
"""Verify that a pickle containing a class not in the allowlist is rejected."""
87+
import pickle
88+
fileoperations.read_from_data_file.return_value = pickle.dumps(
89+
object(), protocol=2)
90+
91+
result = LocalState.loads(LOCAL_STATE_PATH)
92+
93+
self.assertIsInstance(result, LocalState)
94+
self.assertDictEqual({}, result.envvarcollector.map)
95+
96+
def test_dumps_loads_roundtrip(self):
97+
"""Verify dumps/loads round-trip through real pickle."""
98+
import os, tempfile
99+
tmpdir = tempfile.mkdtemp()
100+
path = os.path.join(tmpdir, '.localstate')
101+
try:
102+
self.localstate.dumps(path)
103+
result = LocalState.loads(path)
104+
self.assertDictEqual(self.envvarcollector.map, result.envvarcollector.map)
105+
finally:
106+
os.remove(path)
107+
os.rmdir(tmpdir)

0 commit comments

Comments
 (0)