-
Notifications
You must be signed in to change notification settings - Fork 1
Harden read path against RCE via unconditional pickle deserialization #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -38,6 +38,7 @@ | |
| ArcticDbNotYetImplemented, | ||
| NormalizationException, | ||
| UnsortedDataException, | ||
| UnsafePickleReadError, | ||
| ) | ||
| from arcticdb.supported_types import DateRangeInput, time_types as supported_time_types | ||
| from arcticdb.util._versions import IS_PANDAS_TWO, IS_PANDAS_ZERO | ||
|
|
@@ -1367,13 +1368,51 @@ def normalize( | |
| ) | ||
|
|
||
|
|
||
| _ALLOW_PICKLE_READS_ENV_VAR = "ARCTICDB_ALLOW_PICKLE_READS" | ||
| # None => defer to the environment variable. Set explicitly via set_allow_pickle_reads(). | ||
| _allow_pickle_reads_override = None | ||
|
|
||
|
|
||
| def set_allow_pickle_reads(allow): | ||
| """Control whether ArcticDB is allowed to unpickle stored symbols/metadata on read. | ||
|
|
||
| Reading a pickled symbol or user-defined metadata deserializes it with ``pickle.load``, | ||
| which executes arbitrary code embedded in the stored bytes. A writer to shared storage | ||
| could therefore plant a payload that runs in the reader's process (remote code execution), | ||
| so this is disabled by default and reading such a payload raises ``UnsafePickleReadError``. | ||
|
|
||
| Enable this only if you trust every writer to the libraries you read. | ||
|
|
||
| :param allow: ``True``/``False`` to force enable/disable, or ``None`` to defer to the | ||
| ``ARCTICDB_ALLOW_PICKLE_READS`` environment variable. | ||
| """ | ||
| global _allow_pickle_reads_override | ||
| _allow_pickle_reads_override = None if allow is None else bool(allow) | ||
|
|
||
|
|
||
| def _env_allows_pickle_reads(): | ||
| val = os.environ.get(_ALLOW_PICKLE_READS_ENV_VAR) | ||
| if val is None: | ||
| return False | ||
| return val.strip().lower() in ("1", "true", "yes", "on") | ||
|
|
||
|
|
||
| def allow_pickle_reads(): | ||
| """Return whether unpickling stored data on read is currently permitted.""" | ||
| if _allow_pickle_reads_override is not None: | ||
| return _allow_pickle_reads_override | ||
| return _env_allows_pickle_reads() | ||
|
|
||
|
|
||
| class MsgPackNormalizer(Normalizer): | ||
| """ | ||
| Fall back plan for the time being to store arbitrary data | ||
| """ | ||
|
|
||
| def __init__(self, cfg=None): | ||
| self.strict_mode = cfg.strict_mode if cfg is not None else False | ||
| # None => defer to the global allow_pickle_reads() setting. | ||
| self.allow_pickle_reads = None | ||
|
|
||
| def normalize(self, obj, **kwargs): | ||
| disallow_pickle = kwargs.get("disallow_pickle", None) | ||
|
|
@@ -1444,17 +1483,35 @@ def _ext_hook(self, code, data): | |
| return pd.Timedelta(data).to_pytimedelta() | ||
|
|
||
| if code == MsgPackSerialization.PY_PICKLE_2: | ||
| self._check_pickle_read_allowed() | ||
| # If stored in Python2 we want to use raw while unpacking. | ||
| # https://github.com/msgpack/msgpack-python/blob/master/msgpack/_unpacker.pyx#L230 | ||
| data = unpackb(data, raw=True) | ||
| return Pickler.read(data) | ||
|
|
||
| if code == MsgPackSerialization.PY_PICKLE_3: | ||
| self._check_pickle_read_allowed() | ||
| data = unpackb(data, raw=False) | ||
| return Pickler.read(data) | ||
|
|
||
| return ExtType(code, data) | ||
|
|
||
| def _pickle_reads_allowed(self): | ||
| # Instance override takes priority, otherwise defer to the global setting. | ||
| return self.allow_pickle_reads if self.allow_pickle_reads is not None else allow_pickle_reads() | ||
|
Comment on lines
+1499
to
+1501
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: Instance attribute shadows module-level function name The Was this helpful? React with 👍 or 👎 to provide feedback.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Acknowledged — this is intentional and works correctly (attribute access vs. name lookup). The per-instance |
||
|
|
||
| def _check_pickle_read_allowed(self): | ||
| if not self._pickle_reads_allowed(): | ||
| raise UnsafePickleReadError( | ||
| "Refusing to unpickle stored data on read because pickle reads are disabled. " | ||
| "This payload was stored as a pickle (e.g. a pickled symbol or user-defined " | ||
| "metadata) and decoding it would execute arbitrary code embedded in the stored " | ||
| "bytes, which is a remote code execution risk if any untrusted writer can reach " | ||
| "the storage. If you trust every writer to this library, enable pickle reads via " | ||
| "arcticdb.set_allow_pickle_reads(True) or by setting the " | ||
| f"{_ALLOW_PICKLE_READS_ENV_VAR}=1 environment variable." | ||
| ) | ||
|
|
||
| def _should_disallow_pickle(self, disallow_pickle): | ||
| # `disallow_pickle` set by function parameter, has priority | ||
| # Otherwise fallback to library option `strict_mode` | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -134,6 +134,21 @@ def _set_multiprocessing_start_method_for_macos(): | |
| pass | ||
|
|
||
|
|
||
| # ArcticDB refuses to unpickle stored symbols/metadata on read by default (RCE hardening). | ||
| # The test suite writes and reads back its own trusted pickled data, so enable pickle reads | ||
| # for all tests. Default-off behaviour and the opt-in are covered explicitly in | ||
| # tests/unit/arcticdb/version_store/test_normalization.py. | ||
| @pytest.fixture(autouse=True) | ||
| def _allow_pickle_reads_in_tests(): | ||
| from arcticdb.version_store import _normalization | ||
|
|
||
| _normalization.set_allow_pickle_reads(True) | ||
| try: | ||
| yield | ||
| finally: | ||
| _normalization.set_allow_pickle_reads(None) | ||
|
Comment on lines
+141
to
+149
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: Autouse fixture is function-scoped — consider session scope for performance The Was this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
|
|
||
| # silence warnings about custom markers | ||
| def pytest_configure(config): | ||
| config.addinivalue_line("markers", "storage: Mark tests related to storage functionality") | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📝 Info: Module-level _msgpack_metadata singleton correctly participates in the read gate
The
_msgpack_metadatasingleton (_normalization.py:1778) is created at module load withallow_pickle_reads = None. Whendenormalize_user_metadatacalls_msgpack_metadata._msgpack_unpackb(...), the_ext_hookcallback on this singleton will call_check_pickle_read_allowed(), which defers to the globalallow_pickle_reads()function. This means user-metadata deserialization is correctly gated without needing to thread a per-call parameter through the existingdenormalize_user_metadataAPI.(Refers to lines 1835-1839)
Was this helpful? React with 👍 or 👎 to provide feedback.