Resolve paths with realpath to prevent symlink traversal during archive extraction#23015
Conversation
There was a problem hiding this comment.
Code Review
This pull request updates resolve_path in file_utils.py to use os.path.realpath(path) directly, avoiding os.path.abspath which lexically collapses .. before symlinks are resolved. It also adds a test to verify that files routed through symlinks are rejected. The reviewer identified a critical security vulnerability where this check can still be bypassed if the generator is eagerly evaluated before symlinks are written to disk. The reviewer recommended tracking symlinks in-memory during filtering to ensure robust path traversal protection.
| def resolve_path(path): | ||
| return os.path.realpath(os.path.abspath(path)) | ||
| # Use `realpath` (which also makes the path absolute). Do NOT wrap it | ||
| # in `abspath`: abspath collapses `..` lexically before symlinks are | ||
| # resolved, so `dir/link/../x` becomes `dir/x` without following | ||
| # `link`. That lets a member written through an in-bounds symlink | ||
| # escape the `resolve_sub_path` containment check. | ||
| return os.path.realpath(path) |
There was a problem hiding this comment.
Security Vulnerability: Fragile Symlink Resolution during Eager Evaluation
While using os.path.realpath(path) directly instead of wrapping it in os.path.abspath resolves the lexical collapse issue when the symlink exists on disk, this approach remains highly fragile and vulnerable to path traversal under common usage patterns.
The Problem
The containment check relies on the physical filesystem state to resolve symlinks. However, during archive extraction, filter_safe_tarinfos is a generator. If a user or wrapper eagerly evaluates this generator (e.g., by calling list(filter_safe_tarinfos(...)) or pre-filtering members before extraction), none of the symlinks have been written to disk yet.
In this scenario:
os.path.realpathis called onbase_dir/link/../escaped.txt.- Since
linkdoes not exist on disk yet,os.path.realpathfalls back to lexical resolution and collapseslink/... - The path resolves to
base_dir/escaped.txt, which is deemed safe and accepted. - When extraction actually runs, the symlink
linkis created first, and the subsequent extraction oflink/../escaped.txttraverses the symlink and writes outside the extraction directory.
The Solution
To make this completely robust and immune to eager evaluation or out-of-order processing, we should track the symlinks defined in the archive in-memory and resolve the member paths against these tracked symlinks before performing the containment check.
Here is how filter_safe_tarinfos can be securely implemented:
def filter_safe_tarinfos(members, base_dir):
base_dir = resolve_path(base_dir)
resolved_symlinks = {}
for finfo in members:
# Resolve the member's path against any symlinks we've seen so far in the archive
name = finfo.name
components = name.split("/")
resolved = []
for c in components:
if not c or c == ".":
continue
if c == "..":
if resolved and resolved[-1] != "..":
resolved.pop()
else:
resolved.append("..")
continue
resolved.append(c)
current_path = "/".join(resolved)
if current_path in resolved_symlinks:
target = resolved_symlinks[current_path]
resolved.pop()
for tc in target.split("/"):
if not tc or tc == ".":
continue
if tc == "..":
if resolved and resolved[-1] != "..":
resolved.pop()
else:
resolved.append("..")
continue
resolved.append(tc)
resolved_name = "/".join(resolved)
valid_path = False
if finfo.issym() or finfo.islnk():
if is_link_in_dir(finfo, base_dir):
valid_path = True
if finfo.issym():
resolved_symlinks[name] = finfo.linkname
yield finfo
elif resolve_sub_path(base_dir, resolved_name) is not None:
valid_path = True
yield finfo
if not valid_path:
warnings.warn(
"Skipping invalid path during archive extraction: "
f"'{finfo.name}'.",
stacklevel=2,
)There was a problem hiding this comment.
In Keras this generator is consumed lazily, not via list(...). extract_open_archive passes it straight to tarfile.extractall, which iterates members and extracts each one before pulling the next. So by the time the generator checks a member routed through an in-bounds symlink (the symlink necessarily appears earlier in the archive), that symlink is already on disk and os.path.realpath follows it — the escape is rejected. That is exactly what the added test covers.
The bypass you describe requires the generator to be fully materialized before any extraction begins, which Keras never does. If you'd prefer defense-in-depth against external callers that might eagerly evaluate it, in-memory symlink tracking could be added as a follow-up; I've kept this PR to the minimal root-cause fix.
| def test_file_routed_through_symlink_is_rejected(self): | ||
| """A member routed through an in-bounds symlink via `..` must not | ||
| escape the base dir. | ||
|
|
||
| The symlink (``link`` -> ``.``) is itself within the base dir, but a | ||
| member named ``link/../escaped.txt`` resolves through it to the parent | ||
| directory. The containment check must reject it (regression: a lexical | ||
| ``..`` collapse would cancel ``link/..`` without ever following the | ||
| symlink). | ||
| """ | ||
| os.symlink(".", os.path.join(self.base_dir, "link")) | ||
| with tarfile.open(self.tar_path, "w") as tar: | ||
| tar.add( | ||
| self.target_path, | ||
| arcname=os.path.join("link", "..", "escaped.txt"), | ||
| ) | ||
| with tarfile.open(self.tar_path, "r") as tar: | ||
| members = list( | ||
| file_utils.filter_safe_tarinfos(tar.getmembers(), self.base_dir) | ||
| ) | ||
| self.assertEqual(members, []) |
There was a problem hiding this comment.
Test Setup Limitation
Notice that in this test, you had to manually create the symlink on disk before calling filter_safe_tarinfos:
os.symlink(".", os.path.join(self.base_dir, "link"))This is because filter_safe_tarinfos currently relies on the physical filesystem to resolve symlinks. In a real-world scenario, when a user or library eagerly evaluates the generator (e.g., list(filter_safe_tarinfos(...))) before extraction begins, the symlink does not exist on disk yet, and the containment check would be bypassed.
If we implement the in-memory symlink tracking solution suggested in file_utils.py, this test can be run without manually creating the symlink on disk beforehand, making it a true representation of the extraction process.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #23015 +/- ##
=======================================
Coverage 84.68% 84.68%
=======================================
Files 464 464
Lines 68627 68627
Branches 11272 11272
=======================================
Hits 58117 58117
Misses 7572 7572
Partials 2938 2938
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
| # Use `realpath` (which also makes the path absolute). Do NOT wrap it | ||
| # in `abspath`: abspath collapses `..` lexically before symlinks are | ||
| # resolved, so `dir/link/../x` becomes `dir/x` without following | ||
| # `link`. That lets a member written through an in-bounds symlink | ||
| # escape the `resolve_sub_path` containment check. |
There was a problem hiding this comment.
Remove comment. This won't really make sense outside of this PR and the context can be found by using "Blame" and looking at the PR.
There was a problem hiding this comment.
Done — removed the comment. The rationale now lives in the commit message (findable via Blame).
| """A member routed through an in-bounds symlink via `..` must not | ||
| escape the base dir. | ||
|
|
||
| The symlink (``link`` -> ``.``) is itself within the base dir, but a | ||
| member named ``link/../escaped.txt`` resolves through it to the parent | ||
| directory. The containment check must reject it (regression: a lexical | ||
| ``..`` collapse would cancel ``link/..`` without ever following the | ||
| symlink). | ||
| """ |
There was a problem hiding this comment.
Remove comment. This won't really make sense outside of this PR and the context can be found by using "Blame" and looking at the PR.
There was a problem hiding this comment.
Done — collapsed it to a single-line docstring matching the other tests in this class.
`resolve_path` wrapped `realpath` with `abspath`, which collapses `..` lexically before symlinks are resolved. For an archive member named `link/../x`, the `..` cancels the `link` component as text, so `realpath` never follows the symlink; `resolve_sub_path` then judges the member to be inside the extraction directory while `tarfile.extractall` writes it through the real symlink to a location outside it. A TAR with an in-bounds symlink `link -> .` plus a file `link/../<name>` therefore writes `<name>` outside the extraction directory (chainable to deeper paths) via `get_file(extract=True)` / `extract_archive`. Use `os.path.realpath(path)` directly: it returns an absolute path and follows symlinks component by component, so `..` is applied after the symlink is resolved and the containment check is correct.
8679897 to
4ab48b1
Compare
|
Hi @hertschuh, Happy to provide any additional details if needed. |
* Version bump to 3.12.3 * Fix: KerasFileEditor eject HDF5 ExternalLink/SoftLink groups (#22899) * KerasFileEditor: reject HDF5 ExternalLink/SoftLink groups `_extract_weights_from_store` walked the HDF5 tree with raw subscripts (`data[key]`, `value["vars"]`). When the input file is attacker-supplied, either of those subscripts transparently follows an `h5py.ExternalLink` or `h5py.SoftLink` group into another HDF5 file on the host. The attacker-controlled payload can be as small as ~944 bytes and contains no Python code, so it bypasses any static-analysis-based defense. After the link is followed, `editor.weights_dict` is populated from the linked file. A subsequent `editor.save(...)` — part of the documented edit workflow — writes those tensors back out as regular datasets, turning the editor into a full cross-file disclosure / exfiltration channel for any HDF5 file (e.g. another user's weights checkpoint on a shared training cluster, an `~/.cache/huggingface/...` cached model, etc.) that the victim process can read. PR #22057 (the original CVE-2026-1669 fix) already added a dataset-level `value.external` check to this same method, but only covered `Dataset` external storage. The matching `ExternalLink` / `SoftLink` guard on `Group` children — which is what `safe_get_h5_group` in `saving_lib` was written to provide — was not applied here. Apply it now: * Before subscripting `data[key]`, fetch the link class via `data.get(..., getclass=True, getlink=True)` and raise `ValueError` on `ExternalLink` / `SoftLink`. This mirrors `safe_get_h5_group` but handles the case where the child can be a `Dataset` rather than a `Group`. * Resolve `value["vars"]` through `saving_lib.safe_get_h5_group` so a nested link named `vars` cannot be used to redirect the editor. Keras's own `save_model` / `save_weights` never emits `ExternalLink` or `SoftLink`, so legitimate weight files are unaffected — only attacker- crafted payloads are rejected. Two regression tests cover both link kinds. Reported via huntr: https://huntr.com/bounties/d5688f27-73af-4853-8855-0c3a45c0f290 * KerasFileEditor: trim review comments Shorten the ExternalLink/SoftLink guard comment, restore the original "vars" recursion comment, and drop the added test docstrings/comments per review. No behavior change. * Fix: load_weights reject HDF5 ExternalLink/SoftLink on legacy .h5 dispatcher (#22900) * load_weights: route legacy `.h5` `model_weights` through safe_get_h5_group `saving_api.py` was the only remaining raw `f["model_weights"]` subscript on the legacy HDF5 weight-loading path. h5py transparently follows `h5py.ExternalLink` / `h5py.SoftLink` on plain subscripts, so a ~1 KB attacker-supplied `.h5` whose `model_weights` group is an `ExternalLink` redirects `keras.saving.load_weights(model, "x.h5")` (equivalently `model.load_weights("x.h5")`) into any HDF5 file the victim process can read. The contents land in the victim's in-process model and become trivially exfiltrable via `model.get_weights()`, `model.save(...)`, or inference. This is the same vulnerability class as CVE-2026-1669 and is a missed spot relative to PR #22057 and #22801, which already routed the equivalent accesses in `saving_lib.py`, `file_editor.py`, and `legacy/saving/legacy_h5_format.py` through `safe_get_h5_group` (introduced specifically for this purpose). `safe_mode` does not gate this path — it only guards Python-object deserialization. The fix is a one-line change here: use `saving_lib.safe_get_h5_group` at this dispatcher access, matching what `legacy_h5_format` already does for its own `f["model_weights"]` read. Keras's own `save_model` / `save_weights` never emits `ExternalLink` / `SoftLink`, so legitimate `.h5` weight files are unaffected; only attacker-crafted payloads are rejected. Reported via huntr: https://huntr.com/bounties/d5688f27-73af-4853-8855-0c3a45c0f290 * load_weights: trim review comments Remove the safe_get_h5_group dispatcher comment and the added test docstrings/comments per review. No behavior change. * Fix: reject hard-link tar members whose name escapes the extraction directory (#22973) * Reject hard links whose name escapes the extraction directory filter_safe_tarinfos / is_link_in_dir validated only the target (info.linkname) of a hard-link tar member, never the link's own destination (info.name). A crafted tar with a hard-link member whose name is an absolute path or contains "../" therefore passed the filter and was materialized outside the extraction directory by tarfile.extractall, allowing arbitrary file write/overwrite via keras.utils.get_file(extract=True) / extract_archive on Python < 3.12 (where tarfile's data filter is not applied). Validate info.name for hard links the same way regular files and symbolic links are already validated, completing the tar-extraction path-traversal hardening for the hard-link case. * Address review: shorten hard-link comment and drop redundant test Condense the is_link_in_dir hard-link comment to a single line per reviewer request; context lives in the PR description and blame. Remove FilterSafePathsTest.test_hardlink_name_traversal_skipped: the hardlink_name_out parameterized case in IsLinkInDirTest already covers rejection of a hard link whose name escapes base_dir. * Fix: reject HDF5 shape-bomb datasets in the main load_model/load_weights path (#22975) * Reject HDF5 datasets that declare far more data than is stored safe_get_h5_dataset materialized an h5py dataset sized to its declared shape without checking how much data is actually stored on disk. A crafted .keras/.weights.h5 with a chunked, compressed, fill-value-only dataset can declare an enormous shape (e.g. petabytes) while occupying a few kilobytes, forcing load_model/load_weights to attempt a huge allocation and OOM-kill the process (CWE-789 / CWE-409). Reject datasets whose declared in-memory size both exceeds a floor and exceeds the on-disk storage size by a large factor. Genuine arrays (even compressed) stay well within this bound; shape/decompression bombs do not. This extends the KerasFileEditor-only size guard to the main load_model/load_weights path. * Address review: 4 GiB floor, readable sizes, drop redundant test - Raise _H5_DATASET_BOMB_FLOOR_BYTES from 1 GiB to 4 GiB for consistency with the KerasFileEditor fix (PR #21880). - Format the declared/stored byte counts in the error with readable_memory_size so PiB-scale values are legible. - Remove SafeGetH5DatasetTest.test_allows_regular_dataset; normal dataset loading is already covered by the existing round-trip tests. * Fix: KerasFileEditor reject HDF5 virtual datasets (#22976) * Fix: KerasFileEditor reject HDF5 virtual datasets KerasFileEditor._extract_weights_from_store reads each leaf dataset with a raw value[()]. It already rejects group-level ExternalLink/SoftLink (#22899) and Dataset external storage (#22057, the CVE-2026-1669 fix), but never checks value.is_virtual. An HDF5 Virtual Dataset is a HardLink with external=None, so it passes both guards, yet h5py resolves its source mapping on read and returns bytes from another HDF5 file the victim process can read. The leaked tensors surface via editor.weights_dict / weights_summary() and are re-emitted as plain datasets by editor.save(), reproducing the CVE-2026-1669 cross-file disclosure channel via virtual storage instead of links. saving_lib.safe_get_h5_dataset already rejects virtual datasets; this path did not route through it. Add the matching is_virtual check inline. Regression test covers the VDS case. * Address review comments - file_editor.py: drop the explanatory comment and include current_inner_path in the virtual-dataset rejection message so the user sees which dataset triggered it (Rule 140 / contextual error). - file_editor_test.py: reuse a single self.get_temp_dir() for both fixture files; rename victim_private.weights.h5 -> other.weights.h5 and vds_payload.weights.h5 -> virtual.weights.h5 (and the matching *_fpath locals) to use neutral names. * Reject ZIP archive members that declare far more data than is stored (#23010) `_load_model_from_fileobj` reads `config.json`, `model.weights.h5` and the shard map from the `.keras` archive without bounding the decompressed size. The `config.json` read is the first thing load_model does, before any other validation and regardless of `safe_mode`. A crafted `.keras` can store a few KB on disk yet declare a multi-gigabyte member, so loading it allocates memory (or extracts to disk) for the declared size and the process is OOM-killed or fills the disk (CWE-409). Add `_reject_zip_bomb` (rejects a member whose declared size both exceeds a 4 GiB floor -- matching the HDF5 dataset guard for CVE-2026-0897 -- and exceeds its on-disk size by more than 100x) and a `_safe_zip_read` that checks then reads. Apply it to: - `config.json` and the shard map, via `_safe_zip_read`; - `model.weights.h5`, via `_reject_zip_bomb` placed *before* the in-memory/extract-to-disk/on-the-fly `try/except` -- the bare `except` falls back to reading the weights on the fly, so a check inside it would be swallowed and the bomb still read; checking up front covers all three paths. Keras writes archive members uncompressed (ratio ~1:1) and DEFLATE cannot exceed ~1032:1, so genuine artifacts -- including arbitrarily large legitimate models -- are unaffected. Co-authored-by: hertschuh <1091026+hertschuh@users.noreply.github.com> * Resolve paths with realpath to prevent symlink traversal in extraction (#23015) `resolve_path` wrapped `realpath` with `abspath`, which collapses `..` lexically before symlinks are resolved. For an archive member named `link/../x`, the `..` cancels the `link` component as text, so `realpath` never follows the symlink; `resolve_sub_path` then judges the member to be inside the extraction directory while `tarfile.extractall` writes it through the real symlink to a location outside it. A TAR with an in-bounds symlink `link -> .` plus a file `link/../<name>` therefore writes `<name>` outside the extraction directory (chainable to deeper paths) via `get_file(extract=True)` / `extract_archive`. Use `os.path.realpath(path)` directly: it returns an absolute path and follows symlinks component by component, so `..` is applied after the symlink is resolved and the containment check is correct. * Reject npz weight members that declare far more data than is stored (#23016) `NpzIOStore.get` returns `self.contents[path].tolist()`. Accessing `self.contents[path]` makes NumPy allocate an array sized to the `.npy` header's declared shape before any stored data is validated, so a tiny npz member can declare an enormous shape and drive an unbounded allocation. `keras.saving.load_model` selects the npz reader purely from the archive file list (`model.weights.npz` present and `model.weights.h5` absent), so an attacker who supplies the `.keras` file controls this path: a ~2 KB file can force an arbitrarily large allocation (clean `MemoryError`, or an OOM kill for a shape tuned near available RAM whose zero-filled member compresses heavily). `safe_mode` does not gate weight I/O. Peek each member's `.npy` header and reject it when the declared array size exceeds both a 4 GiB floor and 100x the bytes actually stored, before the array is materialized. Co-authored-by: hertschuh <1091026+hertschuh@users.noreply.github.com> * Validate DiskIOStore asset paths stay within the working directory (#23017) `DiskIOStore.make`/`get` joined a caller-supplied asset path onto the working directory (and `make` created it) without verifying that it stays inside that directory. This is the one asset I/O path in the save/load pipeline that skips the containment already applied to archive extraction (`filter_safe_tarinfos`/`filter_safe_zipinfos`) and Orbax asset writes (`_write_nested_dict_to_dir`), both of which use `resolve_sub_path`. Apply the same `resolve_sub_path` check in `make`/`get`/`has_path` so an asset path that escapes the working directory is rejected. Normal nested asset paths are unaffected. * 🛡️ Sentinel: [HIGH] Fix insecure deserialization in dataset utilities (#23026) * security: set allow_pickle=False for np.load in dataset utilities np.load defaults to allow_pickle=False for security reasons, to prevent insecure deserialization vulnerabilities that could lead to arbitrary code execution if a maliciously crafted `.npz` file is loaded. Several datasets in `keras/src/datasets` used `allow_pickle=True` unnecessarily for numeric arrays (e.g. `mnist`, `boston_housing`, `california_housing`). This commit removes the explicit `allow_pickle=True` argument for those datasets, enhancing the security of the application. Co-authored-by: rni418 <70032655+rni418@users.noreply.github.com> * security: set allow_pickle=False for np.load in dataset utilities np.load defaults to allow_pickle=False for security reasons, to prevent insecure deserialization vulnerabilities that could lead to arbitrary code execution if a maliciously crafted `.npz` file is loaded. Several datasets in `keras/src/datasets` used `allow_pickle=True` unnecessarily for numeric arrays (e.g. `mnist`, `boston_housing`, `california_housing`). This commit removes the explicit `allow_pickle=True` argument for those datasets, enhancing the security of the application. Co-authored-by: rni418 <70032655+rni418@users.noreply.github.com> * security: set allow_pickle=False for np.load in dataset utilities np.load defaults to allow_pickle=False for security reasons, to prevent insecure deserialization vulnerabilities that could lead to arbitrary code execution if a maliciously crafted `.npz` file is loaded. Several datasets in `keras/src/datasets` used `allow_pickle=True` unnecessarily for numeric arrays (e.g. `mnist`, `boston_housing`, `california_housing`). This commit removes the explicit `allow_pickle=True` argument for those datasets, enhancing the security of the application. Co-authored-by: rni418 <70032655+rni418@users.noreply.github.com> * security: set allow_pickle=False for np.load in dataset utilities np.load defaults to allow_pickle=False for security reasons, to prevent insecure deserialization vulnerabilities that could lead to arbitrary code execution if a maliciously crafted `.npz` file is loaded. Several datasets in `keras/src/datasets` used `allow_pickle=True` unnecessarily for numeric arrays (e.g. `mnist`, `boston_housing`, `california_housing`). This commit removes the explicit `allow_pickle=True` argument for those datasets, enhancing the security of the application. Co-authored-by: rni418 <70032655+rni418@users.noreply.github.com> * security: set allow_pickle=False for np.load in dataset utilities np.load defaults to allow_pickle=False for security reasons, to prevent insecure deserialization vulnerabilities that could lead to arbitrary code execution if a maliciously crafted `.npz` file is loaded. Several datasets in `keras/src/datasets` used `allow_pickle=True` unnecessarily for numeric arrays (e.g. `mnist`, `boston_housing`, `california_housing`). This commit removes the explicit `allow_pickle=True` argument for those datasets, enhancing the security of the application. Co-authored-by: rni418 <70032655+rni418@users.noreply.github.com> * security: set allow_pickle=False for np.load in dataset utilities np.load defaults to allow_pickle=False for security reasons, to prevent insecure deserialization vulnerabilities that could lead to arbitrary code execution if a maliciously crafted `.npz` file is loaded. Several datasets in `keras/src/datasets` used `allow_pickle=True` unnecessarily for numeric arrays (e.g. `mnist`, `boston_housing`, `california_housing`). This commit removes the explicit `allow_pickle=True` argument for those datasets, enhancing the security of the application. Co-authored-by: rni418 <70032655+rni418@users.noreply.github.com> * security: set allow_pickle=False for np.load in dataset utilities np.load defaults to allow_pickle=False for security reasons, to prevent insecure deserialization vulnerabilities that could lead to arbitrary code execution if a maliciously crafted `.npz` file is loaded. Several datasets in `keras/src/datasets` used `allow_pickle=True` unnecessarily for numeric arrays (e.g. `mnist`, `boston_housing`, `california_housing`). This commit removes the explicit `allow_pickle=True` argument for those datasets, enhancing the security of the application. Co-authored-by: rni418 <70032655+rni418@users.noreply.github.com> --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> * 🛡️ Sentinel: [ENHANCEMENT] Explicitly disable pickle in `np.load` (#23034) * 🛡️ Sentinel: [ENHANCEMENT] Explicitly disable pickle in `np.load` 🚨 Severity: LOW / ENHANCEMENT 💡 Vulnerability: `np.load` was called without explicitly specifying `allow_pickle=False`. While modern NumPy versions default to `False`, relying on defaults can lead to insecure deserialization if the environment uses an older version or the default changes. 🎯 Impact: Insecure deserialization can lead to arbitrary code execution if a maliciously crafted `.npz` file is loaded. 🔧 Fix: Explicitly pass `allow_pickle=False` to `np.load` in `keras/src/saving/saving_lib.py`. ✅ Verification: Ran `pytest keras/src/saving/saving_lib_test.py` to ensure no regressions. Co-authored-by: rni418 <70032655+rni418@users.noreply.github.com> * 🛡️ Sentinel: [ENHANCEMENT] Explicitly disable pickle in `np.load` 🚨 Severity: LOW / ENHANCEMENT 💡 Vulnerability: `np.load` was called without explicitly specifying `allow_pickle=False`. While modern NumPy versions default to `False`, relying on defaults can lead to insecure deserialization if the environment uses an older version or the default changes. 🎯 Impact: Insecure deserialization can lead to arbitrary code execution if a maliciously crafted `.npz` file is loaded. 🔧 Fix: Explicitly pass `allow_pickle=False` to `np.load` in `keras/src/saving/saving_lib.py`. ✅ Verification: Ran `pytest keras/src/saving/saving_lib_test.py` to ensure no regressions. Co-authored-by: rni418 <70032655+rni418@users.noreply.github.com> * 🛡️ Sentinel: [ENHANCEMENT] Explicitly disable pickle in `np.load` 🚨 Severity: LOW / ENHANCEMENT 💡 Vulnerability: `np.load` was called without explicitly specifying `allow_pickle=False`. While modern NumPy versions default to `False`, relying on defaults can lead to insecure deserialization if the environment uses an older version or the default changes. 🎯 Impact: Insecure deserialization can lead to arbitrary code execution if a maliciously crafted `.npz` file is loaded. 🔧 Fix: Explicitly pass `allow_pickle=False` to `np.load` in `keras/src/saving/saving_lib.py`. ✅ Verification: Ran `pytest keras/src/saving/saving_lib_test.py` to ensure no regressions. Co-authored-by: rni418 <70032655+rni418@users.noreply.github.com> * 🛡️ Sentinel: [ENHANCEMENT] Explicitly disable pickle in `np.load` 🚨 Severity: LOW / ENHANCEMENT 💡 Vulnerability: `np.load` was called without explicitly specifying `allow_pickle=False`. While modern NumPy versions default to `False`, relying on defaults can lead to insecure deserialization if the environment uses an older version or the default changes. 🎯 Impact: Insecure deserialization can lead to arbitrary code execution if a maliciously crafted `.npz` file is loaded. 🔧 Fix: Explicitly pass `allow_pickle=False` to `np.load` in `keras/src/saving/saving_lib.py`. ✅ Verification: Ran `pytest keras/src/saving/saving_lib_test.py` to ensure no regressions. Co-authored-by: rni418 <70032655+rni418@users.noreply.github.com> * 🛡️ Sentinel: [ENHANCEMENT] Explicitly disable pickle in `np.load` 🚨 Severity: LOW / ENHANCEMENT 💡 Vulnerability: `np.load` was called without explicitly specifying `allow_pickle=False`. While modern NumPy versions default to `False`, relying on defaults can lead to insecure deserialization if the environment uses an older version or the default changes. 🎯 Impact: Insecure deserialization can lead to arbitrary code execution if a maliciously crafted `.npz` file is loaded. 🔧 Fix: Explicitly pass `allow_pickle=False` to `np.load` in `keras/src/saving/saving_lib.py`. ✅ Verification: Ran `pytest keras/src/saving/saving_lib_test.py` to ensure no regressions. Co-authored-by: rni418 <70032655+rni418@users.noreply.github.com> * 🛡️ Sentinel: [ENHANCEMENT] Explicitly disable pickle in `np.load` 🚨 Severity: LOW / ENHANCEMENT 💡 Vulnerability: `np.load` was called without explicitly specifying `allow_pickle=False`. While modern NumPy versions default to `False`, relying on defaults can lead to insecure deserialization if the environment uses an older version or the default changes. 🎯 Impact: Insecure deserialization can lead to arbitrary code execution if a maliciously crafted `.npz` file is loaded. 🔧 Fix: Explicitly pass `allow_pickle=False` to `np.load` in `keras/src/saving/saving_lib.py`. ✅ Verification: Ran `pytest keras/src/saving/saving_lib_test.py` to ensure no regressions. Co-authored-by: rni418 <70032655+rni418@users.noreply.github.com> * 🛡️ Sentinel: [ENHANCEMENT] Explicitly disable pickle in `np.load` 🚨 Severity: LOW / ENHANCEMENT 💡 Vulnerability: `np.load` was called without explicitly specifying `allow_pickle=False`. While modern NumPy versions default to `False`, relying on defaults can lead to insecure deserialization if the environment uses an older version or the default changes. 🎯 Impact: Insecure deserialization can lead to arbitrary code execution if a maliciously crafted `.npz` file is loaded. 🔧 Fix: Explicitly pass `allow_pickle=False` to `np.load` in `keras/src/saving/saving_lib.py`. ✅ Verification: Ran `pytest keras/src/saving/saving_lib_test.py` to ensure no regressions. Co-authored-by: rni418 <70032655+rni418@users.noreply.github.com> * 🛡️ Sentinel: [ENHANCEMENT] Explicitly disable pickle in `np.load` 🚨 Severity: LOW / ENHANCEMENT 💡 Vulnerability: `np.load` was called without explicitly specifying `allow_pickle=False`. While modern NumPy versions default to `False`, relying on defaults can lead to insecure deserialization if the environment uses an older version or the default changes. 🎯 Impact: Insecure deserialization can lead to arbitrary code execution if a maliciously crafted `.npz` file is loaded. 🔧 Fix: Explicitly pass `allow_pickle=False` to `np.load` in `keras/src/saving/saving_lib.py`. ✅ Verification: Ran `pytest keras/src/saving/saving_lib_test.py` to ensure no regressions. Co-authored-by: rni418 <70032655+rni418@users.noreply.github.com> * 🛡️ Sentinel: [ENHANCEMENT] Explicitly disable pickle in `np.load` 🚨 Severity: LOW / ENHANCEMENT 💡 Vulnerability: `np.load` was called without explicitly specifying `allow_pickle=False`. While modern NumPy versions default to `False`, relying on defaults can lead to insecure deserialization if the environment uses an older version or the default changes. 🎯 Impact: Insecure deserialization can lead to arbitrary code execution if a maliciously crafted `.npz` file is loaded. 🔧 Fix: Explicitly pass `allow_pickle=False` to `np.load` in `keras/src/saving/saving_lib.py`. ✅ Verification: Ran `pytest keras/src/saving/saving_lib_test.py` to ensure no regressions. Co-authored-by: rni418 <70032655+rni418@users.noreply.github.com> --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> * Make Lambda and TorchModuleWrapper from_config fail closed when safe_mode is unset (#23048) * Make Lambda and TorchModuleWrapper from_config fail closed when safe_mode is unset `Lambda.from_config` computed `safe_mode = safe_mode or in_safe_mode()`, which is `None` (falsy) when the layer is deserialized outside of a `SafeModeScope` and without an explicit `safe_mode` -- for example a direct `keras.Model.from_config(...)` / `keras.Sequential.from_config(...)` call, or a layer config that omits the `"module"` key and is routed through the legacy deserializer. In that case the guard did not fire and the marshalled lambda bytecode was loaded via `func_load`, allowing arbitrary code execution. `TorchModuleWrapper.from_config` had the same problem with its `torch.load()` pickle (`if in_safe_mode():`). Treat an unset safe mode as safe (fail closed), matching the handling already used by `TFSMLayer.from_config`: deserialization is refused unless safe mode is explicitly disabled via `safe_mode=False`, `SafeModeScope(False)`, or `keras.config.enable_unsafe_deserialization()`. `load_model` and `model_from_json` are unaffected -- they already establish a `SafeModeScope`. Adds regression tests for both layers and updates the `TorchModuleWrapper` round-trip test to disable safe mode explicitly. * Add `safe_mode` argument to `TorchModuleWrapper.from_config` Follow the `Lambda.from_config` / `TFSMLayer.from_config` pattern: accept `custom_objects` and `safe_mode` keyword arguments. When `safe_mode` is unset, fall back to the ambient `SafeModeScope`, treating an unset scope as safe (fail closed). Update the test to exercise the explicit `safe_mode=False` argument alongside the ambient-scope path. * Use `filter="data"` in `TarFile.extractall` on supported versions of Python 3.10 and 3.11. (#23108) While the security feature of `filter="data"` was introduced in Python 3.12, it was also backported to Python >= 3.10.12 and Python >= 3.11.4. * Add to the list of APIs that should not be part of a reloaded model. (#23115) Some APIs make no sense in the context of reloading a model and present a security risk. Renamed the set and generalized the error message. * Fix saving tests for r3.12 compatibility * remove the unrelated test in this branch --------- Co-authored-by: Ziyu Lin <104151270+LinZiyuu@users.noreply.github.com> Co-authored-by: hertschuh <1091026+hertschuh@users.noreply.github.com> Co-authored-by: Rakesh Iyer <70032655+rni418@users.noreply.github.com> Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Summary
keras.utils.get_file(extract=True)andkeras.utils.extract_archivevalidate eachTAR member against the extraction directory using
resolve_sub_path, which relies onresolve_path:os.path.abspathcollapses..segments lexically (as text) before any symlinkis resolved. For a member named
link/../x, the..cancels thelinkcomponenttextually, so the subsequent
realpathnever traverses the symlink. The containmentcheck then concludes the member stays inside the extraction directory, while
tarfile.extractallwrites it to the real location reached by following thesymlink — outside the directory.
As a result, an archive containing an in-bounds symlink
link -> .plus a regularfile
link/../<name>writes<name>into the parent of the extraction directory(and chaining symlinks reaches deeper paths). Writing outside the extraction directory
during archive extraction is the issue this validation is meant to prevent.
Fix
Use
os.path.realpath(path)directly.realpathalready returns an absolute path andresolves symlinks component by component, so
..is applied after the symlink isresolved and
resolve_sub_pathcorrectly rejects members that escape the basedirectory. For paths without a symlink-then-
..sequence the result is unchanged.Reproduction (before this PR)
Tests
Added
FilterSafePathsTest.test_file_routed_through_symlink_is_rejected. It fails on theprevious implementation and passes with this change. All existing
file_utilstestscontinue to pass.
Contributor agreement