[Security] Replace diskcache to eliminate pickle deserialization (#44549)

Signed-off-by: Russell Bryant <rbryant@redhat.com> Co-authored-by: Claude <noreply@anthropic.com>

Russell Bryant committed Jul 14, 2026 at 23:29 UTC 96d2ceda4bb54d5601db7b464070860856c4f0b5
7 files changed +162 -19
requirements/common.txt
-2
@@ -23,8 +23,6 @@ tiktoken >= 0.6.0 # Required for DBRX tokenizer
23 lm-format-enforcer == 0.11.3
24 llguidance >= 1.7.0, < 1.8.0; platform_machine == "x86_64" or platform_machine == "arm64" or platform_machine == "aarch64" or platform_machine == "ppc64le" or platform_machine == "s390x"
25 outlines_core == 0.2.14
26 -# required for outlines backend disk cache
27 -diskcache == 5.6.3
26 lark == 1.2.2
27 xgrammar >= 0.2.1, < 1.0.0; platform_machine == "x86_64" or platform_machine == "aarch64" or platform_machine == "arm64" or platform_machine == "s390x" or platform_machine == "ppc64le"
28 typing_extensions >= 4.10
requirements/test/cpu.txt
-2
@@ -179,8 +179,6 @@ dill==0.3.8
179 # evaluate
180 # lm-eval
181 # multiprocess
182 -diskcache==5.6.3
183 - # via -r requirements/test/../common.txt
182 distlib==0.3.9
183 # via virtualenv
184 distro==1.9.0
requirements/test/cuda.txt
-4
@@ -194,10 +194,6 @@ dill==0.3.8
194 # evaluate
195 # lm-eval
196 # multiprocess
197 -diskcache==5.6.3
198 - # via
199 - # -c requirements/common.txt
200 - # -r requirements/test/../common.txt
197 distlib==0.3.9
198 # via virtualenv
199 distro==1.9.0
requirements/test/rocm.txt
-4
@@ -187,10 +187,6 @@ dill==0.3.8
187 # evaluate
188 # lm-eval
189 # multiprocess
190 -diskcache==5.6.3
191 - # via
192 - # -c requirements/common.txt
193 - # -r requirements/test/../common.txt
190 distlib==0.4.0
191 # via virtualenv
192 distro==1.9.0
requirements/test/xpu.txt
-4
@@ -128,10 +128,6 @@ dill==0.4.1
128 # evaluate
129 # lm-eval
130 # multiprocess
131 -diskcache==5.6.3
132 - # via
133 - # -c requirements/common.txt
134 - # -r requirements/test/../common.txt
131 distro==1.9.0
132 # via
133 # anthropic
tests/v1/structured_output/test_outlines_cache.py new
+96
@@ -0,0 +1,96 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import tempfile
5 +
6 +import outlines_core as oc
7 +import pytest
8 +
9 +from vllm.v1.structured_output.utils import OutlinesDiskCache
10 +
11 +pytestmark = pytest.mark.cpu_test
12 +
13 +
14 +@pytest.fixture
15 +def vocab():
16 + return oc.Vocabulary(3, {b"a": [0], b"b": [1], b"ab": [2]})
17 +
18 +
19 +@pytest.fixture
20 +def index(vocab):
21 + return oc.Index("ab", vocab)
22 +
23 +
24 +@pytest.fixture
25 +def cache(tmp_path):
26 + return OutlinesDiskCache(str(tmp_path))
27 +
28 +
29 +class TestOutlinesDiskCache:
30 + def test_store_and_retrieve_index(self, cache, index):
31 + cache["test_key"] = index
32 + restored = cache["test_key"]
33 + assert restored.get_initial_state() == index.get_initial_state()
34 + assert restored.get_transitions() == index.get_transitions()
35 + assert restored.get_final_states() == index.get_final_states()
36 +
37 + def test_store_and_retrieve_string(self, cache):
38 + cache.set("__version__", "0.2.14")
39 + assert cache.get("__version__") == "0.2.14"
40 +
41 + def test_contains(self, cache, index):
42 + assert "missing" not in cache
43 + cache["key"] = index
44 + assert "key" in cache
45 +
46 + def test_get_default(self, cache):
47 + assert cache.get("missing", "fallback") == "fallback"
48 + assert cache.get("missing") is None
49 +
50 + def test_missing_key_raises(self, cache):
51 + with pytest.raises(KeyError):
52 + _ = cache["missing"]
53 +
54 + def test_clear(self, cache, index):
55 + cache["key"] = index
56 + cache.set("__version__", "1.0")
57 + cache.clear()
58 + assert "key" not in cache
59 + assert "__version__" not in cache
60 +
61 + def test_overwrite(self, cache, vocab):
62 + index1 = oc.Index("a", vocab)
63 + index2 = oc.Index("b", vocab)
64 + cache["key"] = index1
65 + cache["key"] = index2
66 + restored = cache["key"]
67 + assert restored.get_transitions() == index2.get_transitions()
68 +
69 + def test_persistence_across_instances(self, tmp_path, index):
70 + cache1 = OutlinesDiskCache(str(tmp_path))
71 + cache1["key"] = index
72 +
73 + cache2 = OutlinesDiskCache(str(tmp_path))
74 + restored = cache2["key"]
75 + assert restored.get_transitions() == index.get_transitions()
76 +
77 + def test_creates_directory(self):
78 + with tempfile.TemporaryDirectory() as tmpdir:
79 + subdir = f"{tmpdir}/nested/cache/dir"
80 + cache = OutlinesDiskCache(subdir)
81 + cache.set("__version__", "1.0")
82 + assert cache.get("__version__") == "1.0"
83 +
84 + def test_version_invalidation_flow(self, cache, index):
85 + """Simulates the version-check logic in get_outlines_cache()."""
86 + cache.set("__version__", "0.2.13")
87 + cache["key"] = index
88 +
89 + cached_version = cache.get("__version__")
90 + new_version = "0.2.14"
91 + if cached_version != new_version:
92 + cache.clear()
93 + cache.set("__version__", new_version)
94 +
95 + assert cache.get("__version__") == "0.2.14"
96 + assert "key" not in cache
vllm/v1/structured_output/utils.py
+66 -3
@@ -5,6 +5,7 @@ from __future__ import annotations
5 import hashlib
6 import importlib.metadata
7 import os
8 +import sqlite3
9 import tempfile
10 from collections.abc import Callable
11 from concurrent.futures import ThreadPoolExecutor, TimeoutError
@@ -214,19 +215,81 @@ def get_outlines_cache_path() -> str:
215 return os.path.join(tempdir, ".cache", "outlines")
216
217
218 +class OutlinesDiskCache:
219 + """SQLite-backed cache for outlines_core.Index objects.
220 +
221 + Uses outlines_core's native binary serialization (via Rust serde)
222 + instead of pickle, eliminating arbitrary code execution risk on
223 + deserialization.
224 + """
225 +
226 + _TYPE_INDEX = "I"
227 + _TYPE_STRING = "S"
228 +
229 + def __init__(self, path: str):
230 + os.makedirs(path, exist_ok=True)
231 + db_path = os.path.join(path, "outlines_cache.db")
232 + self._db = sqlite3.connect(db_path, check_same_thread=False)
233 + self._db.execute("PRAGMA journal_mode=WAL")
234 + self._db.execute(
235 + "CREATE TABLE IF NOT EXISTS cache "
236 + "(key TEXT PRIMARY KEY, type_tag TEXT NOT NULL, value BLOB NOT NULL)"
237 + )
238 + self._db.commit()
239 +
240 + def __contains__(self, key: str) -> bool:
241 + row = self._db.execute("SELECT 1 FROM cache WHERE key=?", (key,)).fetchone()
242 + return row is not None
243 +
244 + def __getitem__(self, key: str):
245 + row = self._db.execute(
246 + "SELECT type_tag, value FROM cache WHERE key=?", (key,)
247 + ).fetchone()
248 + if row is None:
249 + raise KeyError(key)
250 + type_tag, data = row
251 + if type_tag == self._TYPE_STRING:
252 + return data.decode("utf-8")
253 + return oc.Index.from_binary(data)
254 +
255 + def __setitem__(self, key: str, value):
256 + if isinstance(value, str):
257 + type_tag = self._TYPE_STRING
258 + data = value.encode("utf-8")
259 + else:
260 + type_tag = self._TYPE_INDEX
261 + data = value.__reduce__()[1][0]
262 + self._db.execute(
263 + "INSERT OR REPLACE INTO cache (key, type_tag, value) VALUES (?, ?, ?)",
264 + (key, type_tag, data),
265 + )
266 + self._db.commit()
267 +
268 + def get(self, key: str, default=None):
269 + try:
270 + return self[key]
271 + except KeyError:
272 + return default
273 +
274 + def set(self, key: str, value):
275 + self[key] = value
276 +
277 + def clear(self):
278 + self._db.execute("DELETE FROM cache")
279 + self._db.commit()
280 +
281 +
282 def get_outlines_cache():
283 """Get the Cache instance to be used for index caching"""
284
285 cache_dir = get_outlines_cache_path()
286 if envs.VLLM_V1_USE_OUTLINES_CACHE:
222 - from diskcache import Cache
223 -
287 logger.warning(
288 "Enabling outlines cache. This is an unbounded on-disk "
289 "cache. It may consume a lot of disk space and should "
290 "not be used with untrusted clients."
291 )
229 - cache = Cache(cache_dir, eviction_policy="none", cull_limit=0)
292 + cache = OutlinesDiskCache(cache_dir)
293 outlines_version = importlib.metadata.version("outlines_core")
294
295 cached_version = cache.get("__version__", None)