Skip to content

Commit fbbe45b

Browse files
Sunny-AnandtitaiwangmsCopilotandife
authored
Cherry pick commits into the release branch (#7718)
### Motivation and Context Fixes # [Improve external data file handling - onnx.load (](https://github.com/onnx/onnx/commit/4755f8053928dce18a61db8fec71b69c74f786cb)[#7717](https://github.com/onnx/onnx/pull/7717)[)](https://github.com/onnx/onnx/commit/4755f8053928dce18a61db8fec71b69c74f786cb) [remove link for check_urls action (](https://github.com/onnx/onnx/commit/3e48beb7c9d10be868bf80cfd949ae50e4ba87ed)[#7713](https://github.com/onnx/onnx/pull/7713)[)](https://github.com/onnx/onnx/commit/3e48beb7c9d10be868bf80cfd949ae50e4ba87ed) [Update check_urls.yml (](https://github.com/onnx/onnx/commit/2c0a9bef1e2eb2d58bcd39eeef3718578914cdf1)[#7716](https://github.com/onnx/onnx/pull/7716)[)](https://github.com/onnx/onnx/commit/2c0a9bef1e2eb2d58bcd39eeef3718578914cdf1) --------- Signed-off-by: Ti-Tai Wang <titaiwang@microsoft.com> Signed-off-by: Sunny Anand <Sunnyanand.979@gmail.com> Signed-off-by: Andreas Fehlner <fehlner@arcor.de> Co-authored-by: Ti-Tai Wang <titaiwang@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Andreas Fehlner <fehlner@arcor.de>
1 parent 624108e commit fbbe45b

8 files changed

Lines changed: 408 additions & 48 deletions

File tree

.github/workflows/check_urls.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ name: Check URLs
66

77
on:
88
push:
9-
branches: [ 'rel-*' ]
9+
branches: [ "main", rel-* ]
1010
schedule:
1111
# Run every month
1212
- cron: '0 0 1 * *'

docs/Security.md

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<!--
2+
Copyright (c) ONNX Project Contributors
3+
4+
SPDX-License-Identifier: Apache-2.0
5+
-->
6+
7+
# External Data Security
8+
9+
This document describes the security model for loading and saving external data files in ONNX models. It is intended for maintainers working on the external data code paths.
10+
11+
## Threat Model
12+
13+
When an ONNX model references external data files via relative paths, an attacker who controls the model file can attempt:
14+
15+
- **Symlink traversal**: A final-component symlink in the external data path pointing to a sensitive file (e.g., `/etc/shadow`), causing ONNX to read or overwrite arbitrary files.
16+
- **Parent-directory symlink**: A symlink in a parent directory component of the external data path, bypassing a check that only inspects the final component.
17+
- **Hardlink attacks**: A hardlink to a sensitive file appearing as a normal file, bypassing symlink-only checks while still exposing unintended data.
18+
- **Path traversal**: Using `..` segments or absolute paths to escape the model directory.
19+
20+
## Defense Layers
21+
22+
We use a 4-layer defense-in-depth approach. Each layer is applied at every entry point that opens external data files.
23+
24+
### Layer 1: Canonical Path Containment
25+
26+
- **C++**: `std::filesystem::weakly_canonical()` resolves the path, then verifies it starts with the canonical base directory.
27+
- **Python**: `os.path.realpath()` resolves all symlinks in the full path, then verifies the result is within the model base directory.
28+
29+
This catches `..` traversal and symlinks in any path component (not just the final one).
30+
31+
### Layer 2: Symlink Detection
32+
33+
- **C++**: `std::filesystem::is_symlink(data_path)` rejects the final-component symlink.
34+
- **Python**: `os.path.islink(path)` rejects the final-component symlink.
35+
36+
This is a belt-and-suspenders check alongside containment. It provides a clear, specific error message when the final path component is a symlink.
37+
38+
### Layer 3: O_NOFOLLOW on File Open (Python only)
39+
40+
- **Python**: `os.O_NOFOLLOW` added to `os.open()` flags where available (`hasattr(os, "O_NOFOLLOW")`).
41+
42+
The C++ checker validates paths but does not open files, so `O_NOFOLLOW` is not applicable there. In Python, this is the last-resort defense: even if a symlink is created between the check and the open (TOCTOU race), the kernel rejects the open with `ELOOP` on Linux/macOS.
43+
44+
### Layer 4: Hardlink Count Check
45+
46+
- **C++**: `std::filesystem::hard_link_count(data_path) > 1` rejects files with multiple hardlinks.
47+
- **Python**: `os.stat(path).st_nlink > 1` rejects files with multiple hardlinks.
48+
49+
This prevents an attacker from using a hardlink (which is not a symlink) to point external data at a sensitive file. Note that `O_NOFOLLOW` does **not** protect against hardlinks — only this explicit check does.
50+
51+
## Protected Entry Points
52+
53+
Not all layers apply at every entry point. The C++ checker validates paths but does not open files, so Layer 3 (O_NOFOLLOW) is Python-only.
54+
55+
| Entry Point | File | Layers |
56+
|---|---|---|
57+
| `_resolve_external_data_location` | `onnx/checker.cc` | 1, 2, 4 |
58+
| `load_external_data_for_tensor` | `onnx/external_data_helper.py` | 1, 2, 3, 4 |
59+
| `save_external_data` | `onnx/external_data_helper.py` | 1, 2, 3, 4 |
60+
| `ModelContainer._load_large_initializers` | `onnx/model_container.py` | 1, 2, 3, 4 |
61+
62+
The C++ checker runs first for all Python load paths (via `c_checker._resolve_external_data_location`). The Python checks serve as defense-in-depth.
63+
64+
## Known Limitations
65+
66+
### TOCTOU (Time-of-Check-to-Time-of-Use)
67+
68+
There is an inherent race window between the security checks (Layers 1-2, 4) and the file open (Layer 3). An attacker with write access to the model directory could:
69+
70+
1. Place a legitimate file to pass checks.
71+
2. Replace it with a symlink or hardlink between the check and the open.
72+
73+
**Mitigation**: `O_NOFOLLOW` (Layer 3) catches late symlink replacement on Linux/macOS at the kernel level. However, `O_NOFOLLOW` does **not** protect against hardlink replacement — this TOCTOU gap cannot be fully closed at the application level.
74+
75+
### Windows
76+
77+
- `O_NOFOLLOW` is **not available** on Windows (`hasattr(os, "O_NOFOLLOW")` returns `False`). The TOCTOU window for symlink attacks is fully open on Windows, relying solely on Layers 1-2.
78+
- Symlink and hardlink tests are skipped on Windows in the test suite.
79+
80+
### Case-Insensitive Filesystems
81+
82+
The canonical path containment check uses string comparison. On case-insensitive filesystems (Windows NTFS, macOS HFS+), paths with different casing may incorrectly fail containment. This fails closed (false rejection, not a bypass).
83+
84+
## Testing
85+
86+
Test coverage is in:
87+
88+
- **C++**: `onnx/test/cpp/checker_test.cc``SymLink*` tests for symlink detection and containment.
89+
- **Python**: `onnx/test/test_external_data.py`:
90+
- `TestSaveExternalDataSymlinkProtection` — save-side symlink rejection.
91+
- `TestLoadExternalDataSymlinkProtection` — load-side symlink rejection, parent-directory symlink, `load_external_data_for_model` rejection.
92+
- `TestLoadExternalDataHardlinkProtection` — load-side hardlink rejection.
93+
- `TestSaveExternalDataAbsolutePathValidation` — absolute path rejection.
94+
95+
Symlink and hardlink tests are skipped on Windows (`os.name == "nt"`).

onnx/backend/test/case/model/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ def expect(
4141
)
4242

4343

44-
# BASE_URL = "https://download.onnxruntime.ai/onnx/models"
4544
BASE_URL = "onnx/backend/test/data/light/light_%s.onnx"
4645

4746

onnx/checker.cc

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1021,6 +1021,45 @@ std::string resolve_external_data_location(
10211021
data_path_str,
10221022
", but it is a symbolic link.");
10231023
}
1024+
// Verify the resolved path stays within the base directory to prevent
1025+
// path traversal via symlinks in parent directory components.
1026+
// is_symlink() only checks the final component; a path like
1027+
// "symlink_subdir/real_file.data" would bypass it.
1028+
if (data_path_str[0] != '#') {
1029+
std::error_code ec;
1030+
auto canonical_base = std::filesystem::weakly_canonical(base_dir_path, ec);
1031+
if (ec) {
1032+
fail_check(
1033+
"Data of TensorProto ( tensor name: ",
1034+
tensor_name,
1035+
") references external data at ",
1036+
data_path_str,
1037+
", but the model directory path could not be resolved.");
1038+
}
1039+
auto canonical_data = std::filesystem::weakly_canonical(data_path, ec);
1040+
if (ec) {
1041+
fail_check(
1042+
"Data of TensorProto ( tensor name: ",
1043+
tensor_name,
1044+
") references external data at ",
1045+
data_path_str,
1046+
", but the data path could not be resolved.");
1047+
}
1048+
auto canonical_base_native = canonical_base.native();
1049+
auto canonical_data_native = canonical_data.native();
1050+
if (!canonical_base_native.empty() && canonical_base_native.back() != std::filesystem::path::preferred_separator) {
1051+
canonical_base_native += std::filesystem::path::preferred_separator;
1052+
}
1053+
if (canonical_data_native.find(canonical_base_native) != 0) {
1054+
fail_check(
1055+
"Data of TensorProto ( tensor name: ",
1056+
tensor_name,
1057+
") at ",
1058+
data_path_str,
1059+
" resolves to a location outside the model directory, "
1060+
"indicating a potential path traversal attack via symbolic links in directory components.");
1061+
}
1062+
}
10241063
if (data_path_str[0] != '#' && !std::filesystem::is_regular_file(data_path)) {
10251064
fail_check(
10261065
"Data of TensorProto ( tensor name: ",

onnx/external_data_helper.py

Lines changed: 60 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,53 @@ def __init__(self, tensor: TensorProto) -> None:
4343
self.length = int(self.length)
4444

4545

46+
def _validate_external_data_path(
47+
base_dir: str,
48+
data_path: str,
49+
tensor_name: str,
50+
*,
51+
check_exists: bool = True,
52+
) -> str:
53+
"""Validate that an external data path is safe to open.
54+
55+
Performs three security checks:
56+
1. Canonical path containment — resolved path must stay within base_dir.
57+
2. Symlink rejection — final-component symlinks are not allowed.
58+
3. Hardlink count — files with multiple hard links are rejected.
59+
60+
Args:
61+
base_dir: The model base directory that data_path must be contained in.
62+
data_path: The external data file path to validate.
63+
tensor_name: Tensor name for error messages.
64+
check_exists: If True (default), check hardlink count. Set to False
65+
for save-side paths where the file may not exist yet.
66+
67+
Returns:
68+
The validated data_path (unchanged).
69+
70+
Raises:
71+
onnx.checker.ValidationError: If any security check fails.
72+
"""
73+
real_base = os.path.realpath(base_dir)
74+
real_path = os.path.realpath(data_path)
75+
if not real_path.startswith(real_base + os.sep) and real_path != real_base:
76+
raise onnx_checker.ValidationError(
77+
f"Tensor {tensor_name!r} external data path resolves to "
78+
f"{real_path!r} which is outside the model directory {real_base!r}."
79+
)
80+
if os.path.islink(data_path):
81+
raise onnx_checker.ValidationError(
82+
f"Tensor {tensor_name!r} external data path {data_path!r} "
83+
f"is a symbolic link, which is not allowed for security reasons."
84+
)
85+
if check_exists and os.path.exists(data_path) and os.stat(data_path).st_nlink > 1:
86+
raise onnx_checker.ValidationError(
87+
f"Tensor {tensor_name!r} external data path {data_path!r} "
88+
f"has multiple hard links, which is not allowed for security reasons."
89+
)
90+
return data_path
91+
92+
4693
def load_external_data_for_tensor(tensor: TensorProto, base_dir: str) -> None:
4794
"""Loads data from an external file for tensor.
4895
Ideally TensorProto should not hold any raw data but if it does it will be ignored.
@@ -55,7 +102,14 @@ def load_external_data_for_tensor(tensor: TensorProto, base_dir: str) -> None:
55102
external_data_file_path = c_checker._resolve_external_data_location( # type: ignore[attr-defined]
56103
base_dir, info.location, tensor.name
57104
)
58-
with open(external_data_file_path, "rb") as data_file:
105+
# Security checks (symlink, containment, hardlink) already performed
106+
# by C++ _resolve_external_data_location() above.
107+
# Use O_NOFOLLOW where available as defense-in-depth for symlink protection
108+
open_flags = os.O_RDONLY
109+
if hasattr(os, "O_NOFOLLOW"):
110+
open_flags |= os.O_NOFOLLOW
111+
fd = os.open(external_data_file_path, open_flags)
112+
with os.fdopen(fd, "rb") as data_file:
59113
if info.offset:
60114
data_file.seek(info.offset)
61115

@@ -219,21 +273,11 @@ def save_external_data(tensor: TensorProto, base_path: str) -> None:
219273

220274
external_data_file_path = os.path.join(base_path, info.location)
221275

222-
# Verify the resolved path stays within base_path (prevent symlink-based path traversal)
223-
real_base = os.path.realpath(base_path)
224-
real_path = os.path.realpath(external_data_file_path)
225-
if not real_path.startswith(real_base + os.sep) and real_path != real_base:
226-
raise onnx_checker.ValidationError(
227-
f"Tensor {tensor.name!r} external data path resolves to "
228-
f"{real_path!r} which is outside the model directory {real_base!r}."
229-
)
230-
231-
# Reject symlinks to prevent arbitrary file overwrites
232-
if os.path.islink(external_data_file_path):
233-
raise onnx_checker.ValidationError(
234-
f"Tensor {tensor.name!r} external data path {external_data_file_path!r} "
235-
f"is a symbolic link, which is not allowed for security reasons."
236-
)
276+
# C++ _resolve_external_data_location() cannot be used on save path
277+
# (file may not exist yet), so Python performs its own security validation.
278+
_validate_external_data_path(
279+
base_path, external_data_file_path, tensor.name, check_exists=True
280+
)
237281

238282
# Retrieve the tensor's data from raw_data or load external file
239283
if not tensor.HasField("raw_data"):

onnx/model_container.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,10 +293,17 @@ def _load_large_initializers(self, file_path):
293293
external_data_file_path = c_checker._resolve_external_data_location( # type: ignore[attr-defined]
294294
base_dir, info.location, tensor.name
295295
)
296+
# Security checks (symlink, containment, hardlink) already performed
297+
# by C++ _resolve_external_data_location() above.
296298
key = f"#t{i}"
297299
_set_external_data(tensor, location=key)
298300

299-
with open(external_data_file_path, "rb") as data_file:
301+
# Use O_NOFOLLOW where available for symlink protection
302+
open_flags = os.O_RDONLY
303+
if hasattr(os, "O_NOFOLLOW"):
304+
open_flags |= os.O_NOFOLLOW
305+
fd = os.open(external_data_file_path, open_flags)
306+
with os.fdopen(fd, "rb") as data_file:
300307
if info.offset:
301308
data_file.seek(info.offset)
302309

onnx/test/cpp/checker_test.cc

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// SPDX-License-Identifier: Apache-2.0
44

55
#include <filesystem>
6+
#include <fstream>
67
#include <memory>
78
#include <string>
89

@@ -32,23 +33,70 @@ TEST(CHECKER, ValidDataLocationTest) {
3233
}
3334

3435
TEST(CHECKER, ValidDataLocationSymLinkTest) {
35-
#ifndef ONNX_NO_EXCEPTIONS
36-
fs::path tempDir = fs::temp_directory_path() / "symlink_test-%%%%%%"; // NOSONAR
37-
fs::create_directories(tempDir);
38-
fs::path target = tempDir / "model.data";
39-
fs::path link = tempDir / "link.data";
36+
#if !defined(ONNX_NO_EXCEPTIONS) && !defined(_WIN32)
37+
// Use a temp directory as the base_dir (simulating the model directory).
38+
// We pass a relative filename as the location so that the absolute-path
39+
// rejection (checker.cc line 986) is NOT triggered, and the is_symlink()
40+
// check (checker.cc line 1016) is actually exercised.
41+
fs::path modelDir = fs::temp_directory_path() / "onnx_symlink_checker_test";
42+
fs::remove_all(modelDir);
43+
fs::create_directories(modelDir);
44+
45+
// Create a regular target file so the symlink has a valid target.
46+
fs::path target = modelDir / "target.data";
47+
{
48+
std::ofstream ofs(target);
49+
ofs << "test data";
50+
}
51+
52+
// Create a symlink pointing to the target file.
53+
fs::path link = modelDir / "link.data";
4054
fs::create_symlink(target, link);
41-
#ifdef WIN32
42-
std::string location = link.u8string();
43-
#else
44-
std::string location = link.c_str();
55+
56+
// Pass relative filename "link.data" — the checker resolves it to
57+
// modelDir/link.data and should reject it because it is a symlink.
58+
EXPECT_THROW(
59+
ONNX_NAMESPACE::checker::resolve_external_data_location(modelDir.string(), "link.data", "tensor_name"),
60+
ONNX_NAMESPACE::checker::ValidationError);
61+
62+
fs::remove_all(modelDir);
4563
#endif
64+
}
65+
66+
TEST(CHECKER, ValidDataLocationParentDirSymLinkTest) {
67+
#if !defined(ONNX_NO_EXCEPTIONS) && !defined(_WIN32)
68+
// Test that symlinks in parent directory components are detected.
69+
// A location like "symlink_subdir/real_file.data" where symlink_subdir
70+
// is a symlink to an outside directory should be rejected by the
71+
// canonical path containment check in checker.cc.
72+
fs::path modelDir = fs::temp_directory_path() / "onnx_parent_symlink_test";
73+
fs::remove_all(modelDir);
74+
fs::create_directories(modelDir);
75+
76+
// Create a target directory outside the model directory.
77+
fs::path outsideDir = fs::temp_directory_path() / "onnx_outside_target";
78+
fs::remove_all(outsideDir);
79+
fs::create_directories(outsideDir);
80+
81+
// Create a real file in the outside directory.
82+
fs::path targetFile = outsideDir / "secret.data";
83+
{
84+
std::ofstream ofs(targetFile);
85+
ofs << "sensitive data";
86+
}
87+
88+
// Create a directory symlink inside modelDir pointing outside.
89+
fs::path symlinkSubdir = modelDir / "subdir";
90+
fs::create_directory_symlink(outsideDir, symlinkSubdir);
91+
92+
// "subdir/secret.data" is a relative path where "subdir" is a symlink.
93+
// The canonical path resolves outside modelDir, so this should be rejected.
4694
EXPECT_THROW(
47-
ONNX_NAMESPACE::checker::resolve_external_data_location("localfolder", location, "tensor_name"),
95+
ONNX_NAMESPACE::checker::resolve_external_data_location(modelDir.string(), "subdir/secret.data", "tensor_name"),
4896
ONNX_NAMESPACE::checker::ValidationError);
49-
fs::remove(link);
50-
fs::remove(target);
51-
fs::remove(tempDir);
97+
98+
fs::remove_all(modelDir);
99+
fs::remove_all(outsideDir);
52100
#endif
53101
}
54102

0 commit comments

Comments
 (0)