Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
7ca5f45
Resolve symlinks when validating external data path for models loaded…
adrianlizarraga Mar 3, 2026
579974d
Add debugging if test does not fail as expected
adrianlizarraga Mar 3, 2026
b1c99d4
Skip one test on Android because we cant pratically create a file out…
adrianlizarraga Mar 3, 2026
d9c2137
Experiment: use same code in wasm
adrianlizarraga Mar 3, 2026
f74cc9a
Merge branch 'main' into adrianl/InMemModel_ExtDataPath_SymLinkCheck
adrianlizarraga Mar 4, 2026
1a35f8d
Dont check cwd on wasm builds
adrianlizarraga Mar 4, 2026
a414b6f
Merge main and resolve conflicts
adrianlizarraga Mar 5, 2026
46bbe20
Use the std::filesystem functions that return a std::error_code inste…
adrianlizarraga Mar 5, 2026
13d87b2
Simplify validation function with helpers
adrianlizarraga Mar 5, 2026
017f1d2
Clean up
adrianlizarraga Mar 5, 2026
2eaa639
Remove base_dir parameter from ValidateExternalDataPath
adrianlizarraga Mar 5, 2026
ced629a
Add handling and tests for relative model paths
adrianlizarraga Mar 5, 2026
627d4af
Detect if CWD == root_dir in tests
adrianlizarraga Mar 6, 2026
f0ef6dd
Update comment on test edge case
adrianlizarraga Mar 6, 2026
9c5a0f6
Start addressing some review comments
adrianlizarraga Mar 9, 2026
a62b6a3
Merge branch 'main' into adrianl/InMemModel_ExtDataPath_SymLinkCheck
adrianlizarraga Mar 9, 2026
75c7e9e
Update test's expected result when the cwd is at the filesystem root …
adrianlizarraga Mar 9, 2026
a41425b
Remove #if __wasm__ from tests to get logs
adrianlizarraga Mar 9, 2026
b8b4328
Merge branch 'main' into adrianl/InMemModel_ExtDataPath_SymLinkCheck
adrianlizarraga Mar 10, 2026
e36fcc5
Address more review comments: defer wasm handling, edit error message…
adrianlizarraga Mar 12, 2026
595055a
Use a set to prevent repeated validation attempts
adrianlizarraga Mar 12, 2026
4cd24c2
Require external data path to exist
adrianlizarraga Mar 12, 2026
020a106
Update comment and use #ifdef __wasm__
adrianlizarraga Mar 12, 2026
0f79804
Use std::move() and update comment to mention wasm logic
adrianlizarraga Mar 12, 2026
6d4050b
Add #ifdef for Win32 test
adrianlizarraga Mar 12, 2026
944213a
Review comments == simplify
adrianlizarraga Mar 12, 2026
6f397ca
Use fs::is_symlink and adjust error messages
adrianlizarraga Mar 13, 2026
c7a6a75
Merge branch 'main' into adrianl/InMemModel_ExtDataPath_SymLinkCheck
adrianlizarraga Mar 13, 2026
7cc1a7c
Add is_symlink error code message to returned status
adrianlizarraga Mar 13, 2026
85951fd
Merge branch 'main' into adrianl/InMemModel_ExtDataPath_SymLinkCheck
adrianlizarraga Mar 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 67 additions & 49 deletions onnxruntime/core/framework/tensorprotoutils.cc
Original file line number Diff line number Diff line change
Expand Up @@ -349,66 +349,84 @@ Status TensorProtoWithExternalDataToTensorProto(
return Status::OK();
}

Status ValidateExternalDataPath(const std::filesystem::path& base_dir,
const std::filesystem::path& location,
const std::filesystem::path& model_path) {
// Reject absolute paths
ORT_RETURN_IF(location.is_absolute(),
"Absolute paths not allowed for external data location");
if (!base_dir.empty()) {
// Resolve and verify the path stays within model directory
auto base_canonical = std::filesystem::weakly_canonical(base_dir);
// If the symlink exists, it resolves to the target path;
// so if the symlink is outside the directory it would be caught here.
auto resolved = std::filesystem::weakly_canonical(base_dir / location);

// Check that resolved path starts with base directory
auto [base_end, resolved_it] = std::mismatch(
base_canonical.begin(), base_canonical.end(),
resolved.begin(), resolved.end());

if (base_end != base_canonical.end()) {
// If validation against logical base_dir fails, we check against the
// real (canonical) path of the model file to support symlinked models
// (e.g. models in Hugging Face Hub local cache).
if (!model_path.empty()) {
auto real_model_dir = std::filesystem::weakly_canonical(model_path).parent_path();

auto [real_base_end, real_resolved_it] = std::mismatch(
real_model_dir.begin(), real_model_dir.end(),
resolved.begin(), resolved.end());

if (real_base_end == real_model_dir.end()) {
return Status::OK();
}
// Wraps std::filesystem::weakly_canonical with error_code handling.
static Status WeaklyCanonicalPath(const std::filesystem::path& path, std::filesystem::path& result) {
std::error_code ec;
result = std::filesystem::weakly_canonical(path, ec);
ORT_RETURN_IF(ec, "Failed to resolve path: ", path, " - ", ec.message());
Comment thread
edgchen1 marked this conversation as resolved.
Outdated
return Status::OK();
}

return ORT_MAKE_STATUS(ONNXRUNTIME, FAIL,
"External data path: ", location, " (resolved path: ", resolved,
") escapes both model directory: ", base_dir,
" and real model directory: ", real_model_dir);
}
// Checks whether `child` is under `base` by comparing path components.
Comment thread
edgchen1 marked this conversation as resolved.
Outdated
static bool IsUnderDirectory(const std::filesystem::path& base, const std::filesystem::path& child) {
auto [base_end, child_it] = std::mismatch(base.begin(), base.end(), child.begin(), child.end());
return base_end == base.end();
}

return ORT_MAKE_STATUS(ONNXRUNTIME, FAIL,
"External data path: ", location, " (resolved path: ", resolved,
") escapes model directory: ", base_dir);
}
} else {
// The basedir is empty, which occurs when 1) the session loads a model from bytes and 2) the application does not
// set an external file folder path via the session config option
// `kOrtSessionOptionsModelExternalInitializersFileFolderPath`.
Status ValidateExternalDataPath(const std::filesystem::path& model_path,
const std::filesystem::path& location) {
// Reject absolute paths
ORT_RETURN_IF(location.is_absolute(), "Absolute paths not allowed for external data location");

// We conservatively check that the normalized relative path does not contain ".." path components that would allow
// access to arbitrary files outside of the current working directory. Based on ONNX checker validation.
#if defined(__wasm__)
if (model_path.empty()) {
// On WASM, filesystem utilities are not expected to always be fully supported.
// So, we only do a lexical check: reject ".." components that would escape the working directory.
Comment thread
edgchen1 marked this conversation as resolved.
Outdated
auto norm_location = location.lexically_normal();

for (const auto& path_component : norm_location) {
if (path_component == ORT_TSTR("..")) {
return ORT_MAKE_STATUS(ONNXRUNTIME, FAIL, "External data path: ", location,
" (model loaded from bytes) escapes working directory");
}
}
return Status::OK();
}
return Status::OK();

// If there's a model_path on WASM, then there may be a virtual filesystem and we fallthrough
// to the logic below that uses std::filesystem utilities.
#endif

// Determine the anchor directory: use model directory if provided, otherwise the current working directory.
std::filesystem::path anchor_dir = model_path.parent_path();

if (anchor_dir.empty()) { // Happens if either model_path.empty() or model_path.parent_path().empty()
anchor_dir = ORT_TSTR(".");
}

// Resolve the anchor directory and the full location path to their canonical forms.
// This resolves symlinks so that a link pointing outside the anchor is detected.
std::filesystem::path anchor_canonical;
ORT_RETURN_IF_ERROR(WeaklyCanonicalPath(anchor_dir, anchor_canonical));
Comment thread
adrianlizarraga marked this conversation as resolved.
Outdated

std::filesystem::path resolved;
ORT_RETURN_IF_ERROR(WeaklyCanonicalPath(anchor_canonical / location, resolved));

if (IsUnderDirectory(anchor_canonical, resolved)) {
return Status::OK();
}

// Fall back to checking against the canonical model directory.
// This supports symlinked models (e.g., Hugging Face Hub local cache) where the canonical
// parent of the model file differs from the logical parent directory.
if (!model_path.empty()) {
Comment thread
adrianlizarraga marked this conversation as resolved.
Outdated
std::filesystem::path real_model_path;
ORT_RETURN_IF_ERROR(WeaklyCanonicalPath(model_path, real_model_path));
Comment thread
adrianlizarraga marked this conversation as resolved.
Outdated
auto real_model_dir = real_model_path.parent_path();

if (!real_model_dir.empty() && IsUnderDirectory(real_model_dir, resolved)) {
Comment thread
edgchen1 marked this conversation as resolved.
Outdated
return Status::OK();
}

return ORT_MAKE_STATUS(ONNXRUNTIME, FAIL,
"External data path: ", location, " (resolved path: ", resolved,
") escapes both model directory: ", anchor_dir,
" and real model directory: ", real_model_dir);
}

return ORT_MAKE_STATUS(ONNXRUNTIME, FAIL,
"External data path for model loaded from bytes escapes working directory. ",
"External data path: ", location, " resolved path: ", resolved, " ",
"working directory: ", anchor_dir);
}

Status GetExternalDataInfo(const ONNX_NAMESPACE::TensorProto& tensor_proto,
Expand Down
15 changes: 8 additions & 7 deletions onnxruntime/core/framework/tensorprotoutils.h
Original file line number Diff line number Diff line change
Expand Up @@ -540,18 +540,19 @@ Status TensorProtoWithExternalDataToTensorProto(

/// <summary>
/// Validates if the external data path is under the model directory.
/// If the model is a symlink, it checks against both the logical model directory (base_dir)
/// The model directory is derived from model_path.parent_path().
/// If the model is a symlink, it checks against both the logical model directory
/// and the real/canonical directory of the model.
Comment thread
edgchen1 marked this conversation as resolved.
Outdated
/// If the `base_dir` is empty, the function only ensures that `location` is not an absolute path.
/// If model_path is empty (model loaded from bytes), the function ensures that `location` is not
/// an absolute path, does not contain ".." components that escape the current working directory, and
/// resolves symlinks to verify the target stays within the current working directory.
Comment thread
edgchen1 marked this conversation as resolved.
Outdated
/// </summary>
/// <param name="base_dir">Logical model location directory</param>
/// <param name="model_path">Path to the model file. If empty, the model was loaded from bytes.</param>
/// <param name="location">Location string retrieved from TensorProto external data</param>
/// <param name="model_path">Optional path to the model file, used for canonical path validation if base_dir check fails</param>
/// <returns>The function will fail if the resolved full path is not under the logical model directory
/// nor the real directory of the model path</returns>
Status ValidateExternalDataPath(const std::filesystem::path& base_dir,
const std::filesystem::path& location,
const std::filesystem::path& model_path = {});
Status ValidateExternalDataPath(const std::filesystem::path& model_path,
const std::filesystem::path& location);
Comment thread
edgchen1 marked this conversation as resolved.
Outdated

#endif // !defined(SHARED_PROVIDER)

Expand Down
6 changes: 1 addition & 5 deletions onnxruntime/core/graph/graph.cc
Original file line number Diff line number Diff line change
Expand Up @@ -3742,10 +3742,6 @@ Status Graph::ConvertInitializersIntoOrtValues() {
FindAllSubgraphs(all_subgraphs);

const auto& model_path = GetModel().ModelPath();
PathString model_dir;
if (!model_path.empty()) {
ORT_RETURN_IF_ERROR(GetDirNameFromFilePath(model_path, model_dir));
}

auto put_weights_maybe_in_memory_func = [&](Graph& graph) -> Status {
// if we have any initializers that are not in memory, put them there.
Expand All @@ -3771,7 +3767,7 @@ Status Graph::ConvertInitializersIntoOrtValues() {
std::unique_ptr<onnxruntime::ExternalDataInfo> external_data_info;
ORT_RETURN_IF_ERROR(onnxruntime::ExternalDataInfo::Create(tensor_proto.external_data(), external_data_info));
const auto& location = external_data_info->GetRelPath();
auto st = utils::ValidateExternalDataPath(model_dir, location, model_path);
auto st = utils::ValidateExternalDataPath(model_path, location);
if (!st.IsOK()) {
return ORT_MAKE_STATUS(ONNXRUNTIME, FAIL,
"External data path validation failed for initializer: ", tensor_proto.name(),
Expand Down
113 changes: 100 additions & 13 deletions onnxruntime/test/framework/tensorutils_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
#include <cstdint>
#include <limits>
#include <fstream>
#include <utility>

#include "gtest/gtest.h"
#include "gmock/gmock.h"
Expand Down Expand Up @@ -519,43 +520,73 @@ class PathValidationTest : public ::testing::Test {
// Clean up the temporary directory.
std::filesystem::remove_all(base_dir_);
std::filesystem::remove_all(outside_dir_);

for (const auto& other_dir : other_dirs_) {
std::filesystem::remove_all(other_dir);
}
}

void AddDirToCleanUp(std::filesystem::path other_dir) {
other_dirs_.push_back(std::move(other_dir));
}

std::filesystem::path base_dir_;
std::filesystem::path outside_dir_;
std::vector<std::filesystem::path> other_dirs_;
};

// Test cases for ValidateExternalDataPath.
TEST_F(PathValidationTest, ValidateExternalDataPath) {
// Use a model path whose parent is base_dir_.
auto model_path = base_dir_ / "model.onnx";

// Valid relative path.
ASSERT_STATUS_OK(utils::ValidateExternalDataPath(base_dir_, "data.bin"));
ASSERT_STATUS_OK(utils::ValidateExternalDataPath(model_path, "data.bin"));

// Empty location.
// Only validate it is not an absolute path.
ASSERT_TRUE(utils::ValidateExternalDataPath(base_dir_, "").IsOK());
ASSERT_TRUE(utils::ValidateExternalDataPath(model_path, "").IsOK());

// Path with ".." that escapes the base directory.
ASSERT_FALSE(utils::ValidateExternalDataPath(base_dir_, "../data.bin").IsOK());
ASSERT_FALSE(utils::ValidateExternalDataPath(model_path, "../data.bin").IsOK());

// Absolute path.
#ifdef _WIN32
ASSERT_FALSE(utils::ValidateExternalDataPath(base_dir_, "C:\\data.bin").IsOK());
ASSERT_FALSE(utils::ValidateExternalDataPath(model_path, "C:\\data.bin").IsOK());
ASSERT_FALSE(utils::ValidateExternalDataPath("", "C:\\data.bin").IsOK());
#else
ASSERT_FALSE(utils::ValidateExternalDataPath(base_dir_, "/data.bin").IsOK());
ASSERT_FALSE(utils::ValidateExternalDataPath(model_path, "/data.bin").IsOK());
ASSERT_FALSE(utils::ValidateExternalDataPath("", "/data.bin").IsOK());
#endif // Absolute path.

// Windows vs Unix path separators.
ASSERT_STATUS_OK(utils::ValidateExternalDataPath(base_dir_, "sub/data.bin"));
ASSERT_STATUS_OK(utils::ValidateExternalDataPath(base_dir_, "sub\\data.bin"));
ASSERT_STATUS_OK(utils::ValidateExternalDataPath(model_path, "sub/data.bin"));
ASSERT_STATUS_OK(utils::ValidateExternalDataPath(model_path, "sub\\data.bin"));

// Base directory does not exist.
ASSERT_STATUS_OK(utils::ValidateExternalDataPath("non_existent_dir", "data.bin"));
// Model in a directory that does not exist.
ASSERT_STATUS_OK(utils::ValidateExternalDataPath("non_existent_dir/model.onnx", "data.bin"));

// Model path is a bare filename (no directory component). parent_path() returns empty,
// so anchor_dir falls back to "." (current directory). Path traversal should still be blocked
// if the current working directory is not the filesystem root directory.
ASSERT_STATUS_OK(utils::ValidateExternalDataPath("model.onnx", "data.bin"));

bool is_cwd_root = std::filesystem::weakly_canonical(".") == std::filesystem::weakly_canonical("..");
ASSERT_EQ(utils::ValidateExternalDataPath("model.onnx", "../data.bin").IsOK(), is_cwd_root);

// Model relative path checks.
ASSERT_STATUS_OK(utils::ValidateExternalDataPath("./model.onnx", "data.bin"));
ASSERT_EQ(utils::ValidateExternalDataPath("./model.onnx", "../data.bin").IsOK(), is_cwd_root);
ASSERT_STATUS_OK(utils::ValidateExternalDataPath("./abc/model.onnx", "data.bin"));
#ifdef _WIN32
ASSERT_STATUS_OK(utils::ValidateExternalDataPath(".\\model.onnx", "data.bin"));
ASSERT_EQ(utils::ValidateExternalDataPath(".\\model.onnx", "../data.bin").IsOK(), is_cwd_root);
ASSERT_STATUS_OK(utils::ValidateExternalDataPath(".\\abc\\model.onnx", "data.bin"));
#endif

//
// Tests for an empty base directory.
// The base directory would be empty when 1) the session loads a model from bytes and 2) the application does not
// Tests for an empty model path (model loaded from bytes).
// The model path would be empty when 1) the session loads a model from bytes and 2) the application does not
// set an external file folder path via the session config option
// kOrtSessionOptionsModelExternalInitializersFileFolderPath.
//
Expand All @@ -579,6 +610,7 @@ TEST_F(PathValidationTest, ValidateExternalDataPath) {

TEST_F(PathValidationTest, ValidateExternalDataPathWithSymlinkInside) {
// Symbolic link that points inside the base directory.
auto model_path = base_dir_ / "model.onnx";
try {
auto target = base_dir_ / "target.bin";
std::ofstream{target};
Expand All @@ -588,11 +620,12 @@ TEST_F(PathValidationTest, ValidateExternalDataPathWithSymlinkInside) {
GTEST_SKIP() << "Skipping symlink tests since symlink creation is not supported in this environment. Exception: "
<< e.what();
}
ASSERT_STATUS_OK(utils::ValidateExternalDataPath(base_dir_, "link.bin"));
ASSERT_STATUS_OK(utils::ValidateExternalDataPath(model_path, "link.bin"));
}

TEST_F(PathValidationTest, ValidateExternalDataPathWithSymlinkOutside) {
// Symbolic link that points outside the base directory.
auto model_path = base_dir_ / "model.onnx";
auto outside_target = outside_dir_ / "outside.bin";
try {
{
Expand All @@ -603,9 +636,63 @@ TEST_F(PathValidationTest, ValidateExternalDataPathWithSymlinkOutside) {
} catch (const std::exception& e) {
GTEST_SKIP() << "Skipping symlink tests since symlink creation is not supported in this environment. Exception: " << e.what();
}
ASSERT_FALSE(utils::ValidateExternalDataPath(base_dir_, "outside_link.bin").IsOK());
ASSERT_FALSE(utils::ValidateExternalDataPath(model_path, "outside_link.bin").IsOK());
}

#if !defined(__wasm__)
TEST_F(PathValidationTest, ValidateExternalDataPathEmptyBasedirWithSymlinkInside) {
// Symbolic link within the current working directory pointing to a file still within CWD.
std::filesystem::path cwd = std::filesystem::current_path();
std::filesystem::path sub_dir = cwd / "symlink_test_subdir";
std::filesystem::create_directories(sub_dir);
AddDirToCleanUp(sub_dir);

try {
std::filesystem::path target = sub_dir / "target_inside.bin";
std::filesystem::path symlink = sub_dir / "link_inside.bin";
std::ofstream{target};
std::filesystem::create_symlink(target, symlink);
} catch (const std::exception& e) {
GTEST_SKIP() << "Skipping symlink tests since symlink creation is not supported in this environment. Exception: "
<< e.what();
}

EXPECT_STATUS_OK(utils::ValidateExternalDataPath("", "./symlink_test_subdir/link_inside.bin"));
}

TEST_F(PathValidationTest, ValidateExternalDataPathEmptyBasedirWithSymlinkOutside) {
// Symbolic link within the current working directory pointing to a file outside CWD.
std::filesystem::path cwd = std::filesystem::current_path();
std::filesystem::path sub_dir = cwd / "symlink_test_subdir2";
std::filesystem::create_directories(sub_dir);
AddDirToCleanUp(sub_dir);

// Check if we can actually make a file outside of the current working directory (i.e., in a temp dir).
// This is only possible if the current working directory is NOT the same as the temp directory.
// Otherwise, we need to skip this test. This happens in Android CI.
Comment thread
edgchen1 marked this conversation as resolved.
Outdated
auto [cwd_end, outside_end] = std::mismatch(cwd.begin(), cwd.end(), outside_dir_.begin(), outside_dir_.end());
if (cwd_end == cwd.end()) {
GTEST_SKIP() << "Skipping test that needs to create a symlink outside of the cwd because the cwd is the same as "
<< "the temp dir. cwd: " << cwd << " outside_dir_: " << outside_dir_;
}

try {
std::filesystem::path outside_target = outside_dir_ / "outside_for_empty_basedir.bin";
std::filesystem::path symlink = sub_dir / "outside_link.bin";
std::ofstream{outside_target};
std::filesystem::create_symlink(outside_target, symlink);
} catch (const std::exception& e) {
GTEST_SKIP() << "Skipping symlink tests since symlink creation is not supported in this environment. Exception: "
<< e.what();
}

Status status = utils::ValidateExternalDataPath("", "./symlink_test_subdir2/outside_link.bin");
ASSERT_FALSE(status.IsOK()) << "Expected validation to fail. cwd: " << cwd << " sub_dir: " << sub_dir
<< " outside_dir: " << outside_dir_;
EXPECT_THAT(status.ErrorMessage(), testing::HasSubstr("escapes working directory"));
}
#endif // !defined(__wasm__)

// Tests for ValidateEmbeddedTensorProtoDataSizeAndShape and embedded initializer size limits

TEST(TensorProtoDataSizeShapeValidationTest, ValidTensorProtoWithRawData) {
Expand Down
Loading