Security Model for External Data in onnx-irΒΆ

This document describes the threat model, implemented defenses, known limitations, and design rationale for how onnx-ir handles external tensor data (the ExternalTensor class).

Threat modelΒΆ

ONNX models can reference external data files via relative paths stored in the location field of a TensorProto. A malicious model could abuse this to read arbitrary files on the host. The main attack vectors are:

Vector

Example

Path traversal

../../etc/passwd

Absolute path

/etc/passwd

Symlink escape

data.bin β†’ /etc/passwd

Hardlink smuggling

Hard-linking a sensitive file into the model directory

Implemented defensesΒΆ

ExternalTensor._check_path_containment() enforces a three-layer check whenever a non-empty base_dir is set:

Layer

What it does

1. String-based containment

Normalizes the path (without resolving symlinks) and verifies it stays within base_dir. Catches ../ traversal and absolute paths without requiring the file to exist.

2. Realpath containment

Resolves all symlinks via os.path.realpath() and re-checks containment. Catches symlinks whose target is outside base_dir. Symlinks that resolve within base_dir are allowed.

3. Hardlink detection

Rejects files with more than one hard link (st_nlink > 1). Prevents an attacker from hard-linking a sensitive file into the model directory to bypass the containment boundary.

All three checks run at load time β€” when numpy(), tofile(), or tobytes() (indirectly, via _load()) is called β€” not at construction time. This allows safe deserialization of untrusted protos without triggering I/O.

save(..., external_data=...), external_data.unload_from_model(), and save_safetensors() also validate external source paths before writing any data. The planned external data file paths are checked before the writer publishes any files as well, so an output symlink cannot leave the saved model pointing outside its directory. An existing attribute reference is preserved only if it passes the same policy relative to the destination model directory. If a readable source cannot be referenced safely from the destination, its bytes are streamed into the new external data file instead. File identity alone does not establish that a destination reference is permitted.

base_dir="" bypass (by design)ΒΆ

When base_dir is empty (the default), all security checks are skipped. This is intentional for two reasons:

  1. Programmatic construction β€” when a developer creates an ExternalTensor in code, they control the paths directly and do not need containment checks.

  2. Deserialization safety β€” the IR deserializer may create ExternalTensor objects before a base_dir is known. Containment is only meaningful when the caller sets base_dir to the model’s directory.

If you are loading an untrusted model, always set base_dir to the directory containing the model file.

Divergence from onnx/onnxΒΆ

The reference ONNX runtime (onnx/onnx#7717) implements a four-layer defense:

Layer

onnx/onnx

onnx-ir

Notes

1. Canonical path containment

βœ…

βœ…

Equivalent

2. Symlink handling

βœ… reject all

βœ… allow within base

Different policy β€” see rationale below

3. O_NOFOLLOW on open

βœ…

❌

Planned β€” see Future hardening

4. Hardlink count check

βœ…

βœ…

Equivalent (added in this PR)

Why the differences?ΒΆ

  • onnx-ir is a library, not a runtime. It focuses on safe loading of model data for inspection and transformation, not sandboxed execution.

  • Symlink policy β€” onnx/onnx rejects all final-component symlinks. onnx-ir allows symlinks whose resolved target stays within base_dir. This is more permissive but still prevents escape from the containment boundary, and avoids breaking legitimate workflows that use symlinks within the model directory (e.g. shared weight files).

  • O_NOFOLLOW closes a TOCTOU (time-of-check-to-time-of-use) race between the containment check and the open() call. This is a valuable defense-in-depth measure but requires platform-specific code (os.O_NOFOLLOW is not available on Windows). It is planned for a future release.

Known limitationsΒΆ

  • TOCTOU window β€” A small race exists between _check_path_containment() and the subsequent open(). An attacker who can modify the filesystem concurrently could swap a safe file for a symlink after the check passes. Mitigation: use O_NOFOLLOW (planned).

  • base_dir="" bypass β€” As described above, an empty base_dir disables all checks. Callers loading untrusted models must set base_dir.

  • Hardlink detection is best-effort β€” The st_nlink check only detects hard links at the time of the check. It cannot prevent hard links created after the check. On some filesystems or operating systems, st_nlink may not accurately reflect the number of hard links.

  • Hardlink collateral β€” When an attacker creates a hard link to a legitimate data file, both the original and the link get st_nlink=2. This means the original file also becomes un-loadable until the extra link is removed. This is fail-closed behavior (safe by default), but operators should be aware of it when diagnosing unexpected load failures.

Future hardeningΒΆ

  • O_NOFOLLOW on file open β€” Use os.open() with O_NOFOLLOW to close the TOCTOU window at the kernel level (Linux/macOS).

  • _open_validated() wrapper β€” Centralize file-open + security checks so future code paths cannot accidentally bypass containment.