Files
InvokeAI/tests/test_object_serializer_disk.py
fac15fe772 fix(api): reject path traversal in object-serializer names and model keys (#9595)
* fix(api): reject path-separator characters in serialized object names

`ObjectSerializerDisk._get_path()` joined the caller-supplied `name`
straight onto the output directory. Names are generated server-side, but
they round-trip through the client inside node fields (`TensorField.
tensor_name`, `LatentsField.latents_name`, `ConditioningField.
conditioning_name`, `DenoiseMaskField.mask_name`), so on the way back in
they are attacker-controlled: any authenticated user who can submit a
graph could put `../` (or an absolute path) in one and make `load()`
`torch.load()` a file anywhere the server process can read. A node that
renders the loaded tensor - `tensor_mask_to_image` is the easy one -
then hands the contents back as an image the caller owns.

Validate at the single choke point: a name must be a plain filename.
`PureWindowsPath` is used for the check so that backslash- and
drive-letter-shaped payloads are rejected on posix too, rather than only
blowing up on Windows deployments.

Also pin `weights_only=True` on the `torch.load()` call. It has been the
torch default since 2.6 and we require >=2.7, so this is a no-op today,
but the file being loaded is named by untrusted input and the safe
globals allowlist is only meaningful while the unpickling path stays
shut.

Reported by Dan Aridor.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix(api): stop client-supplied model keys escaping the model images folder

Extends the object-serializer guard from the previous commit to the other
name-to-path service, and factors the shared predicate out into
`invokeai/app/util/path_safety.py` so the next such service has one
obvious thing to call. Two copies of a security predicate is how this bug
class survived in the first place: the pre-6.7 hardening fixed
`ImageFileStorageDisk.get_path()` and left its siblings alone.

`ModelImageFileStorageDisk` joined an unvalidated `model_key`. Reachable
over HTTP only in its Windows-backslash shape - uvicorn percent-decodes
before Starlette routes, so `%2F` never matches the `{key}` path segment -
but `save()` was also building its own path instead of going through
`get_path()`, so a guard on `get_path()` alone would have left the write
side open.

Worse, `_process_preview_image()` builds
`model_images_path / f"{model_key}.webp"` by hand, bypassing the service
entirely, and `model_key` there is whatever the install request asked for:
`ModelRecordChanges.key` is an optional body field and
`ModelConfigFactory` honours it verbatim. A key of `../../pwned` writes
the thumbnail two levels outside the store. That key also reaches
`dest_dir = models_path / info.key` in `install_path()`, which creates the
directory and moves the model into it. Validating `ModelRecordChanges.key`
closes both at the point the untrusted value enters; the hand-built join
is guarded too, since it is a backend util that takes a bare `Path`.

`update_model_image` and `delete_model_image` now resolve the model record
before touching the filesystem. Neither checked that the key named a model
at all, so an upload for an unknown key created a stray file; resolving
first makes that a 404, and keeps a rejected key from reaching a service
whose `except Exception` would have laundered it into a 500. Note that
`update_model_image` can now return 404, which its `responses` dict does
not declare - declaring it would churn `openapi.json` and `schema.ts`,
which are otherwise byte-identical across this branch.

Two gaps in the predicate itself, from an adversarial review of the
previous commit: `pathlib` keeps trailing dots and spaces but the Win32
normaliser strips them at open time, so `".. "` reached the parent
directory on Windows; and control characters were accepted, which is not
"safe to join" for a name that also ends up in a header or a log line.

Reported by Dan Aridor (the tensor case); the model-key cases were found
while hardening the surrounding code.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix(api): let the image-delete route clean up orphans; check the record under the claim

Review follow-ups on the model-image routes:

- DELETE /i/{key}/image no longer requires the model's record. Deleting a model
  leaves its cover image behind, and this route is how that orphan is removed, so
  the record check made orphans permanent. A missing image or a key that is not a
  plain filename (rejected by the storage service) now maps to 404 instead of 500.
- PATCH /i/{key}/image resolves the record while holding the key claim. Deletion
  and conversion claim the key too, so the model can no longer disappear between
  the check and the save.
- Document the PATCH route's 404 in OpenAPI; regenerate openapi.json and schema.ts.
- Correct the ModelRecordChanges.key comment: install_path()'s join is not
  guarded at the sink (#9596).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(api): case-fold model key claims; make the image-delete traversal test bite

- A cover image is `<key>.webp`, and on a case-insensitive filesystem a case
  variant of a key names the same file. Claims were compared exactly, so an
  image delete for `K` could run while a conversion held `k` and unlink the
  image the conversion was carrying over. Claims are now case-folded.
- The image-delete traversal test only used backslash and `..` keys, which are
  literal (missing) filenames on posix and 404 even with no key validation.
  Add `../escaped`, which does escape the folder when unguarded.
- Correct the PATCH comment: removing an external provider's models does not
  take the claim, so that path can still leave an orphan image.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* test(model-manager): cover legacy unsafe image key

* fix(api): 404 an upload for a legacy unsafe key; guard install_path at the sink

- PATCH /i/{key}/image: a record written before install keys were validated can
  carry a key that is not a plain filename. The storage guard rejected it inside
  save(), which surfaced as an unhandled ModelImageFileSaveException (500). Ask
  the storage service for the path before reading the upload and answer 404.
- Claims use Unicode canonical caseless matching (NFD, casefold, NFD), not bare
  casefold: APFS also ignores normalization, so `é` and `é` name one
  cover image there.
- install_path() refuses a key that is not a plain filename before creating or
  moving anything, so the models_path join no longer relies solely on the
  ModelRecordChanges validator. Closes #9596.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(install): skip a legacy install marker whose key no longer validates

`_restore_incomplete_installs` built `ModelRecordChanges` from each marker's
`config_in` outside the per-marker try. Now that the key is validated, a marker
written by an older version with an unsafe key raised out of the loop at every
startup, leaving every later interrupted install unrestored. Build it inside the
try so that marker is skipped like any other unreadable one.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* test(path-safety): cover NTFS parent stream traversal

* fix(api): reject NTFS stream syntax and Windows device names in stored names

Addresses JPPhoto's round-3 blocker and the fallout from fixing it.

`is_plain_filename()` passed `..:stream`. `PureWindowsPath` only reads a
single *letter* followed by a colon as a drive, so it parses `C:foo` but
hands `..:stream` back whole, and `.name == name` held. On NTFS that
names the alternate data stream `stream` on the file `..`, i.e. the
parent directory, so `<store>/..:stream` writes outside the store while
looking like one bare component. Colons are now rejected outright, along
with the rest of the set Windows forbids in a filename. Extends the one
case in 3895394 to the other shapes that leaked the same way.

Windows device names are rejected too. They do not escape upwards but
they do not stay put either: Win32 resolves `NUL`, `CON`, `COM1` and
friends to the device namespace from any directory, so a write to
`<store>/NUL.webp` is silently discarded while `.exists()` still reports
True. Includes the superscript `COM¹`/`LPT¹` forms and `COM0`/`LPT0`,
and strips trailing spaces from the base name after the extension split,
because Win32 trims them before the device lookup.

The control-character set now covers C1, U+2028 and U+2029, which
`str.splitlines()` treats as line breaks.

The `ModelRecordChanges.key` validator is gone, and the check moved to
where a key is actually adopted. That model is the *update* body as well
as the install body, and the model edit form posts the whole record back
with the key riding along as an echo, so validating the field made any
model whose key predates key validation impossible to edit at all: a 422
before the handler runs, with no key field in the UI to fix it. On Linux
and macOS every one of `: ? * | < > "` is a legal filename character and
no released version checked the key, so such records exist. The same
model is re-parsed from install markers written by older versions, where
rejecting one stranded its partial download forever - no job is created
to clean up and `_remove_dangling_install_dirs` keeps any readable
non-terminal marker.

In its place:

- `update_model_record` refuses to *change* a key. `update_model` copies
  every set field into the stored config, so a `key` in the body landed
  in the blob while the row id kept the old one, leaving a record that
  answers to a key nothing can look it up by - `replace_model`, and so
  reidentify, raises `UnknownModelException` from then on, and the UI
  caches the model under the new key so every later request 404s. The
  old validator did not close this: a *valid* but different key poisoned
  the row just the same. Gating on the key changing accepts the form's
  echo and closes the write.
- `_probe()` checks the key. It is the one place a caller-supplied key
  becomes a record's key, and unlike the previous check in
  `install_path()` it also covers `register_path()`, i.e. every in-place
  install. External registration builds its config directly, so it
  carries its own check.
- `install_model` keeps a route-level check so a bad key is a 422 rather
  than a job that fails later.
- `migration_24` leaves a model in place rather than `mkdir`+`rename`ing
  it into `models_dir / key`, which was the one join in the repo with no
  containment check and takes its key from the stored blob.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* test(model-images): cover legacy POSIX keys

* fix(model-images): keep existing covers for keys that are legal on posix

The store's join guard used `is_plain_filename`, which applies Windows' rules on
every platform. That is the right gate for a key we are about to accept, but no
released version checked model keys, and on posix `: ? * | < > " \` and names
like `NUL` are ordinary filename characters. A model keyed `legacy:key` with a
cover on disk lost it on upgrade: `get_url()` returned None and the image
PATCH/DELETE routes answered 404, so it could not be replaced or removed.

Add `is_contained_filename`, which checks containment on the running platform
only: exactly `is_plain_filename` on Windows, and no empty name, `.`, `..`, `/` or
control character on posix. Use it where a join addresses data that may already
exist - the model-images store and migration_24's relocation. New keys still go
through `is_plain_filename` at every entry point (install route, `_probe`,
external registration, `install_path`), so nothing new can be stored under a
name some deployment could not hold.

Store tests now run the Windows payloads with Windows rules forced, so a posix
CI run still covers them, alongside the posix-escaping subset and the legacy
names that must keep working.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(model-images): encode model keys in URLs; cover migration_24's guard

Follow-ups from self-review of the posix-containment change:

- A legacy posix key can hold URL syntax. With `X?y` now served, its cover URL
  addressed `GET /i/X` and the delete-image button sent `DELETE /i/X?y/image`,
  which routes to `DELETE /i/{key}` for model `X`. Percent-encode the key in
  `get_model_image_url` and in every model route the frontend builds.
- `get_url()` returns None when `exists()` raises (ENAMETOOLONG for an
  over-long stored key), instead of failing the whole model list.
- Test migration_24's relocation guard: an escaping key stays in place, a
  Windows-only key stays in place under Windows rules, and a legacy posix key is
  normalized like any other.
- Skip the forced-posix legacy-key store test on Windows, where `get_path`
  builds a real WindowsPath that splits `a\b`; drop `trailing.`, which the
  strict predicate accepts anyway.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
Co-authored-by: JPPhoto <[email protected]>
2026-09-26 19:56:51 +00:00

387 lines
16 KiB
Python

import tempfile
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from pathlib import Path
from threading import Barrier
import pytest
import torch
from invokeai.app.services.object_serializer.object_serializer_common import (
InvalidObjectNameError,
ObjectNotFoundError,
)
from invokeai.app.services.object_serializer.object_serializer_disk import ObjectSerializerDisk
from invokeai.app.services.object_serializer.object_serializer_forward_cache import ObjectSerializerForwardCache
@dataclass
class MockDataclass:
foo: str
def count_files(path: Path):
return len(list(path.iterdir()))
@pytest.fixture
def obj_serializer(tmp_path: Path):
return ObjectSerializerDisk[MockDataclass](tmp_path, safe_globals=[MockDataclass])
@pytest.fixture
def fwd_cache(tmp_path: Path):
return ObjectSerializerForwardCache(
ObjectSerializerDisk[MockDataclass](tmp_path, safe_globals=[MockDataclass]), max_cache_size=2
)
def test_obj_serializer_disk_initializes(tmp_path: Path):
obj_serializer = ObjectSerializerDisk[MockDataclass](tmp_path, safe_globals=[MockDataclass])
assert obj_serializer._output_dir == tmp_path
def test_obj_serializer_disk_saves(obj_serializer: ObjectSerializerDisk[MockDataclass]):
obj_1 = MockDataclass(foo="bar")
obj_1_name = obj_serializer.save(obj_1)
assert Path(obj_serializer._output_dir, obj_1_name).exists()
obj_2 = MockDataclass(foo="baz")
obj_2_name = obj_serializer.save(obj_2)
assert Path(obj_serializer._output_dir, obj_2_name).exists()
def test_obj_serializer_disk_loads(obj_serializer: ObjectSerializerDisk[MockDataclass]):
obj_1 = MockDataclass(foo="bar")
obj_1_name = obj_serializer.save(obj_1)
assert obj_serializer.load(obj_1_name).foo == "bar"
obj_2 = MockDataclass(foo="baz")
obj_2_name = obj_serializer.save(obj_2)
assert obj_serializer.load(obj_2_name).foo == "baz"
with pytest.raises(ObjectNotFoundError):
obj_serializer.load("nonexistent_object_name")
def test_obj_serializer_disk_deletes(obj_serializer: ObjectSerializerDisk[MockDataclass]):
obj_1 = MockDataclass(foo="bar")
obj_1_name = obj_serializer.save(obj_1)
obj_2 = MockDataclass(foo="bar")
obj_2_name = obj_serializer.save(obj_2)
obj_serializer.delete(obj_1_name)
assert not Path(obj_serializer._output_dir, obj_1_name).exists()
assert Path(obj_serializer._output_dir, obj_2_name).exists()
def test_obj_serializer_ephemeral_creates_tempdir(tmp_path: Path):
obj_serializer = ObjectSerializerDisk[MockDataclass](tmp_path, safe_globals=[MockDataclass], ephemeral=True)
assert isinstance(obj_serializer._tempdir, tempfile.TemporaryDirectory)
assert obj_serializer._base_output_dir == tmp_path
assert obj_serializer._output_dir != tmp_path
assert obj_serializer._output_dir == Path(obj_serializer._tempdir.name)
def test_obj_serializer_ephemeral_deletes_tempdir(tmp_path: Path):
obj_serializer = ObjectSerializerDisk[MockDataclass](tmp_path, safe_globals=[MockDataclass], ephemeral=True)
tempdir_path = obj_serializer._output_dir
del obj_serializer
assert not tempdir_path.exists()
def test_obj_serializer_ephemeral_deletes_tempdir_on_stop(tmp_path: Path):
obj_serializer = ObjectSerializerDisk[MockDataclass](tmp_path, safe_globals=[MockDataclass], ephemeral=True)
tempdir_path = obj_serializer._output_dir
obj_serializer.stop(None) # pyright: ignore [reportArgumentType]
assert not tempdir_path.exists()
def test_obj_serializer_ephemeral_writes_to_tempdir(tmp_path: Path):
obj_serializer = ObjectSerializerDisk[MockDataclass](tmp_path, safe_globals=[MockDataclass], ephemeral=True)
obj_1 = MockDataclass(foo="bar")
obj_1_name = obj_serializer.save(obj_1)
assert Path(obj_serializer._output_dir, obj_1_name).exists()
assert not Path(tmp_path, obj_1_name).exists()
def test_obj_serializer_ephemeral_deletes_dangling_tempdirs_on_init(tmp_path: Path):
tempdir = tmp_path / "tmpdir"
tempdir.mkdir()
ObjectSerializerDisk[MockDataclass](tmp_path, safe_globals=[MockDataclass], ephemeral=True)
assert not tempdir.exists()
def test_obj_serializer_does_not_delete_tempdirs_on_init(tmp_path: Path):
tempdir = tmp_path / "tmpdir"
tempdir.mkdir()
ObjectSerializerDisk[MockDataclass](tmp_path, safe_globals=[MockDataclass], ephemeral=False)
assert tempdir.exists()
def test_obj_serializer_disk_different_types(tmp_path: Path):
obj_serializer_1 = ObjectSerializerDisk[MockDataclass](tmp_path, safe_globals=[MockDataclass])
obj_1 = MockDataclass(foo="bar")
obj_1_name = obj_serializer_1.save(obj_1)
obj_1_loaded = obj_serializer_1.load(obj_1_name)
assert obj_serializer_1._obj_class_name == "MockDataclass"
assert isinstance(obj_1_loaded, MockDataclass)
assert obj_1_loaded.foo == "bar"
assert obj_1_name.startswith("MockDataclass_")
obj_serializer_2 = ObjectSerializerDisk[int](tmp_path, safe_globals=[int])
obj_2_name = obj_serializer_2.save(9001)
assert obj_serializer_2._obj_class_name == "int"
assert obj_serializer_2.load(obj_2_name) == 9001
assert obj_2_name.startswith("int_")
obj_serializer_3 = ObjectSerializerDisk[str](tmp_path, safe_globals=[str])
obj_3_name = obj_serializer_3.save("foo")
assert obj_serializer_3._obj_class_name == "str"
assert obj_serializer_3.load(obj_3_name) == "foo"
assert obj_3_name.startswith("str_")
obj_serializer_4 = ObjectSerializerDisk[torch.Tensor](tmp_path, safe_globals=[torch.Tensor])
obj_4_name = obj_serializer_4.save(torch.tensor([1, 2, 3]))
obj_4_loaded = obj_serializer_4.load(obj_4_name)
assert obj_serializer_4._obj_class_name == "Tensor"
assert isinstance(obj_4_loaded, torch.Tensor)
assert torch.equal(obj_4_loaded, torch.tensor([1, 2, 3]))
assert obj_4_name.startswith("Tensor_")
def test_obj_serializer_fwd_cache_initializes(obj_serializer: ObjectSerializerDisk[MockDataclass]):
fwd_cache = ObjectSerializerForwardCache(obj_serializer)
assert fwd_cache._underlying_storage == obj_serializer
def test_obj_serializer_fwd_cache_saves_and_loads(fwd_cache: ObjectSerializerForwardCache[MockDataclass]):
obj = MockDataclass(foo="bar")
obj_name = fwd_cache.save(obj)
obj_loaded = fwd_cache.load(obj_name)
obj_underlying = fwd_cache._underlying_storage.load(obj_name)
assert obj_loaded == obj_underlying
assert obj_loaded.foo == "bar"
def test_obj_serializer_fwd_cache_respects_cache_size(fwd_cache: ObjectSerializerForwardCache[MockDataclass]):
obj_1 = MockDataclass(foo="bar")
obj_1_name = fwd_cache.save(obj_1)
obj_2 = MockDataclass(foo="baz")
obj_2_name = fwd_cache.save(obj_2)
obj_3 = MockDataclass(foo="qux")
obj_3_name = fwd_cache.save(obj_3)
assert obj_1_name not in fwd_cache._cache
assert obj_2_name in fwd_cache._cache
assert obj_3_name in fwd_cache._cache
# apparently qsize is "not reliable"?
assert fwd_cache._cache_ids.qsize() == 2
def test_obj_serializer_fwd_cache_calls_delete_callback(fwd_cache: ObjectSerializerForwardCache[MockDataclass]):
called_name = None
obj_1 = MockDataclass(foo="bar")
def on_deleted(name: str):
nonlocal called_name
called_name = name
fwd_cache.on_deleted(on_deleted)
obj_1_name = fwd_cache.save(obj_1)
fwd_cache.delete(obj_1_name)
assert called_name == obj_1_name
def test_obj_serializer_fwd_cache_removes_deleted_ids_from_eviction_queue(
fwd_cache: ObjectSerializerForwardCache[MockDataclass],
):
obj_1_name = fwd_cache.save(MockDataclass(foo="bar"))
obj_2_name = fwd_cache.save(MockDataclass(foo="baz"))
fwd_cache.delete(obj_1_name)
obj_3_name = fwd_cache.save(MockDataclass(foo="qux"))
assert obj_1_name not in fwd_cache._cache
assert obj_2_name in fwd_cache._cache
assert obj_3_name in fwd_cache._cache
assert fwd_cache._cache_ids.qsize() == 2
def test_obj_serializer_fwd_cache_stays_bounded_when_deletes_leave_stale_ids(
fwd_cache: ObjectSerializerForwardCache[MockDataclass],
):
# A stale id consumes one eviction slot without freeing an entry. That must not let the queue or the
# cache grow past `max_cache_size`, and the slots must drain as new objects arrive.
for i in range(10):
fwd_cache.delete(fwd_cache.save(MockDataclass(foo=f"deleted-{i}")))
assert len(fwd_cache._cache) <= fwd_cache._max_cache_size
assert fwd_cache._cache_ids.qsize() <= fwd_cache._max_cache_size
# Once saves stop being deleted the cache refills to capacity, and eviction still drops the oldest live
# entries rather than being thrown off by the stale ids ahead of them in the queue.
names = [fwd_cache.save(MockDataclass(foo=f"live-{i}")) for i in range(4)]
assert set(fwd_cache._cache) == set(names[-fwd_cache._max_cache_size :])
def test_obj_serializer_fwd_cache_delete_failure_leaves_cache_and_listeners_untouched(
fwd_cache: ObjectSerializerForwardCache[MockDataclass], monkeypatch: pytest.MonkeyPatch
):
# If the underlying delete fails (the realistic Windows case: the file is still open elsewhere), the
# object is still there. Listeners must not be told otherwise -- `MemoryInvocationCache._delete_by_match`
# is registered on `on_deleted` and would purge cached invocation outputs for a live object.
called_names: list[str] = []
fwd_cache.on_deleted(called_names.append)
obj_name = fwd_cache.save(MockDataclass(foo="bar"))
def raise_permission_error(name: str) -> None:
raise PermissionError(name)
monkeypatch.setattr(fwd_cache._underlying_storage, "delete", raise_permission_error)
with pytest.raises(PermissionError):
fwd_cache.delete(obj_name)
assert called_names == []
assert obj_name in fwd_cache._cache
def test_obj_serializer_fwd_cache_preserves_fifo_order_after_deletion(tmp_path: Path):
fwd_cache = ObjectSerializerForwardCache(
ObjectSerializerDisk[MockDataclass](tmp_path, safe_globals=[MockDataclass]), max_cache_size=3
)
obj_1_name = fwd_cache.save(MockDataclass(foo="one"))
obj_2_name = fwd_cache.save(MockDataclass(foo="two"))
obj_3_name = fwd_cache.save(MockDataclass(foo="three"))
fwd_cache.delete(obj_2_name)
obj_4_name = fwd_cache.save(MockDataclass(foo="four"))
obj_5_name = fwd_cache.save(MockDataclass(foo="five"))
assert obj_1_name not in fwd_cache._cache
assert obj_2_name not in fwd_cache._cache
assert obj_3_name in fwd_cache._cache
assert obj_4_name in fwd_cache._cache
assert obj_5_name in fwd_cache._cache
assert fwd_cache._cache_ids.qsize() == 3
def test_obj_serializer_fwd_cache_delete_of_evicted_object_is_noop(
fwd_cache: ObjectSerializerForwardCache[MockDataclass],
):
obj_1_name = fwd_cache.save(MockDataclass(foo="one"))
obj_2_name = fwd_cache.save(MockDataclass(foo="two"))
obj_3_name = fwd_cache.save(MockDataclass(foo="three"))
cache_before_delete = fwd_cache._cache.copy()
queue_size_before_delete = fwd_cache._cache_ids.qsize()
fwd_cache.delete(obj_1_name)
assert fwd_cache._cache == cache_before_delete
assert fwd_cache._cache_ids.qsize() == queue_size_before_delete
assert obj_2_name in fwd_cache._cache
assert obj_3_name in fwd_cache._cache
def test_obj_serializer_fwd_cache_concurrent_cache_misses_are_not_serialized(
fwd_cache: ObjectSerializerForwardCache[MockDataclass], monkeypatch: pytest.MonkeyPatch
):
# `load()` must not hold `_cache_lock` across the underlying read. One serializer instance is shared by
# every session-processor worker (`dependencies.py` builds a single `tensors` and `conditioning` cache),
# so widening the critical section would make a cache miss on one GPU block the others for a full
# `torch.load`. The barrier only trips if all four reads are in flight at once, which makes this a
# deadlock rather than a timing assertion.
names = [fwd_cache.save(MockDataclass(foo=f"obj-{i}")) for i in range(4)]
fwd_cache._cache.clear() # force every load below to miss
barrier = Barrier(len(names), timeout=10)
underlying_load = fwd_cache._underlying_storage.load
def load_through_barrier(name: str) -> MockDataclass:
barrier.wait()
return underlying_load(name)
monkeypatch.setattr(fwd_cache._underlying_storage, "load", load_through_barrier)
with ThreadPoolExecutor(max_workers=len(names)) as executor:
futures = [executor.submit(fwd_cache.load, name) for name in names]
loaded = [future.result(timeout=10) for future in futures]
assert {obj.foo for obj in loaded} == {f"obj-{i}" for i in range(len(names))}
@pytest.mark.parametrize(
"name",
[
"../escaped",
"../../../../etc/passwd",
"sub/nested",
"/etc/passwd",
"..",
".",
"",
"foo/../../bar",
# Windows-shaped payloads must be rejected on posix too, so a posix-only CI run still covers them.
"..\\escaped",
"sub\\nested",
"C:\\Windows\\win.ini",
"C:relative",
"nul\x00byte",
],
)
def test_obj_serializer_disk_rejects_traversal_names(obj_serializer: ObjectSerializerDisk[MockDataclass], name: str):
"""Object names arrive from the client inside node fields (e.g. `TensorField.tensor_name`), so a name that is
not a plain filename must never be joined onto the output directory."""
with pytest.raises(InvalidObjectNameError):
obj_serializer.load(name)
with pytest.raises(InvalidObjectNameError):
obj_serializer.delete(name)
with pytest.raises(InvalidObjectNameError):
obj_serializer._get_path(name)
def test_obj_serializer_disk_traversal_name_does_not_read_outside_dir(tmp_path: Path):
"""The end-to-end shape of the reported issue: a file outside the store, named by a `..` traversal, must not
be deserialized."""
store = tmp_path / "store"
store.mkdir()
secret = tmp_path / "secret.pt"
torch.save(torch.ones((2, 2), dtype=torch.bool), secret)
obj_serializer = ObjectSerializerDisk[torch.Tensor](store, safe_globals=[torch.Tensor])
with pytest.raises(InvalidObjectNameError):
obj_serializer.load(f"../{secret.name}")
def test_obj_serializer_disk_accepts_generated_names(obj_serializer: ObjectSerializerDisk[MockDataclass]):
"""The guard must not reject the names the serializer itself hands out."""
name = obj_serializer.save(MockDataclass(foo="bar"))
assert obj_serializer._get_path(name).parent == obj_serializer._output_dir
assert obj_serializer.load(name).foo == "bar"
def test_obj_serializer_fwd_cache_rejects_traversal_names(fwd_cache: ObjectSerializerForwardCache[MockDataclass]):
"""The forward cache must not launder a traversal name past the disk serializer's guard."""
with pytest.raises(InvalidObjectNameError):
fwd_cache.load("../escaped")
with pytest.raises(InvalidObjectNameError):
fwd_cache.delete("../escaped")
class UnregisteredClass:
"""Deliberately never passed to `add_safe_globals`."""
def __init__(self) -> None:
self.foo = "bar"
def test_obj_serializer_disk_does_not_unpickle_arbitrary_objects(tmp_path: Path):
"""Object names are untrusted, so even a name that stays inside the store must not reach a full unpickle: a
file planted in the store (or swapped for one) must not be able to instantiate arbitrary classes."""
obj_serializer = ObjectSerializerDisk[MockDataclass](tmp_path, safe_globals=[MockDataclass])
name = "planted"
torch.save(UnregisteredClass(), tmp_path / name)
with pytest.raises(Exception) as exc_info:
obj_serializer.load(name)
assert "UnregisteredClass" in str(exc_info.value) or "weights_only" in str(exc_info.value)