fix: serialize cache values as JSON instead of pickle - #663
Open
scttfrdmn wants to merge 1 commit into
Open
Conversation
diskcache pickles any value that is not a str, int, float, or bytes, and unpickles it on read. That makes the cache directory a code-execution surface: anything able to write into it can hand a poisoned payload to the next reader (CVE-2025-69872 / GHSA-w8v5-vhqr-4h9v). The CVE is unfixed upstream -- 5.6.3 is the newest release and both proposed fixes were declined -- so dvc-data cannot wait for a patched diskcache. It also does not need pickle's expressiveness. Every value it caches is a dict, bool, tuple of numbers, or an already-JSON-encoded string, so JSON covers the whole domain with no code-execution primitive on read. Add a JSONDisk that re-encodes only the values the base class would have pickled. str, int, float, and bytes keep their raw storage, which leaves HashesCache -- it writes through the disk layer but reads back with raw SQL -- untouched. Keys are also left to the base class, which stores dvc-data's string keys raw and is therefore already pickle-free. Entries written by an older dvc-data are recognised by content rather than a schema version: a protocol-2+ pickle starts with the PROTO opcode (0x80), which no JSON encoding can begin with. Such an entry is refused, evicted, and reported as a miss. These caches all live under tmp_dir and are regenerable, so that costs a recomputation rather than data. Reading a legacy entry evicts it, so the two places that iterate a Cache now materialize their keys and read via get(): ObjectDBIndex.dir_hashes and State.get_unused_links. get_unused_links also compares the stored (inode, mtime) pair as a sequence, since JSON has no tuple type. The existing pickle-protocol tests are updated rather than weakened to expect an error: DiskError stays reachable through pickled *keys*, and the protocol-interop test now asserts a successful read, because values no longer depend on the protocol at all.
|
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
diskcachepickles any value that is not astr,int,float, orbytes, and unpickles it on read. That makes the cache directory a code-execution surface: anything able to write into it can hand a poisoned payload to the next reader — CVE-2025-69872 / GHSA-w8v5-vhqr-4h9v.The CVE is unfixed upstream: 5.6.3 is the newest release and both proposed fixes (grantjenks/python-diskcache#358, #364) were declined. So a version floor is not available to us, and
dvc-datais what puts three of its four cache call sites in the pickling path.Which sites pickle is not obvious.
Disk.storedispatches on exact types (type(value) is int), soboolis not covered by theintcase:hashfile/state.pylinks(inode, mtime)tuplehashfile/db/index.pyTrue/Falsebool is not int)index/serialize.pyHashesCacheKeys are already pickle-free — dvc-data's keys are strings, which the base class stores raw.
Approach
dvc-datadoes not need pickle's expressiveness. Every value it caches is a dict, bool, tuple of numbers, or an already-JSON-encoded string, so JSON covers the whole domain with no code-execution primitive on read.JSONDiskre-encodes only the values the base class would have pickled.str/int/float/byteskeep their raw storage, which is what leavesHashesCacheuntouched — it writes through the disk layer but reads back with raw SQL (WHERE ... and raw = 1), so re-encoding those would double-encode them.Migrating existing caches
Entries written by an older
dvc-dataare recognised by content, not a schema version: a protocol-2+ pickle starts with the PROTO opcode0x80, which no JSON encoding can begin with. A legacy entry is refused, evicted, and reported as a miss. All four caches live undertmp_dirand are regenerable, so that costs a recomputation, not data.Reading a legacy entry evicts it, so the two places that iterate a
Cachenow materialize their keys and read viaget()— otherwise.items()races the eviction and raisesKeyError. That's a real bug I only found by exercising theIndexand links paths rather than the cache in isolation.get_unused_linksalso compares the stored(inode, mtime)pair as a sequence, since JSON has no tuple type.On the existing pickle tests
The declined #364 drew the objection that it "changed the existing tests to expect
UnpicklingError… documents the regression but does not resolve it." This does not do that.test_pickle_protocol_errorstill assertsDiskError, because keys are still pickled and that path stays live; the protocol-interop test now asserts a successful read, because values no longer depend on the pickle protocol at all.Verification
src/— 15 failed / 29 passed withsrc/reverted and the tests kept. A security test that was never watched to fail proves nothing.test_poisoned_entry_is_not_executedis an end-to-end exploit: injecting a__reduce__payload into the cache DB executes it against stock diskcache and returns a miss here.ruff check: all checks passed.ruff format --check: clean.min_file_size) path checked separately: round-trips, payload startsb'{', exactly one value file, no orphan.Happy to split the migration handling into its own commit, or to gate the JSON disk behind a setting, if you'd prefer a smaller first step.