diff --git a/onnxruntime/core/framework/tensorprotoutils.cc b/onnxruntime/core/framework/tensorprotoutils.cc index 6f73456742160..275fa837a7257 100644 --- a/onnxruntime/core/framework/tensorprotoutils.cc +++ b/onnxruntime/core/framework/tensorprotoutils.cc @@ -2097,6 +2097,33 @@ void MakeCpuTensorCopy(const Tensor& src_tensor, Tensor& dst_tensor) { } #if !defined(DISABLE_SPARSE_TENSORS) + +// Validates that a TensorProto's external data path does not escape the model directory. +// Also validates that the file exists when filesystem access is available (skipped on WASM without a virtual FS). +// Returns Status::OK() (no-op) for tensors that do not use file-based external data. +static Status ValidateExternalDataPathForTensor(const ONNX_NAMESPACE::TensorProto& tensor_proto, + const std::filesystem::path& model_path) { + // Gates on data_location == EXTERNAL directly instead of using HasExternalData()/HasExternalDataInFile(), + // which also require data_type != UNDEFINED. That check is appropriate for data processing (can't unpack + // without a type), but too narrow for security validation: we must validate any declared external path + // regardless of data_type. + if (tensor_proto.data_location() != ONNX_NAMESPACE::TensorProto_DataLocation_EXTERNAL) { + return Status::OK(); + } + + std::unique_ptr external_data_info; + ORT_RETURN_IF_ERROR(ExternalDataInfo::Create(tensor_proto.external_data(), external_data_info)); + const auto& rel_path = external_data_info->GetRelPath(); + + // In-memory external data uses special marker locations — skip file path validation for those. + if (rel_path == kTensorProtoLittleEndianMemoryAddressTag || + rel_path == kTensorProtoNativeEndianMemoryAddressTag) { + return Status::OK(); + } + + return utils::ValidateExternalDataPath(model_path, rel_path); +} + static Status CopySparseData(const std::string& name, int64_t nnz_elements, const ONNX_NAMESPACE::TensorProto& indices, @@ -2115,10 +2142,18 @@ static Status CopySparseData(const std::string& name, switch (indices.data_type()) { case ONNX_NAMESPACE::TensorProto_DataType_INT64: if (needs_unpack) { - ORT_RETURN_IF_NOT(indices.raw_data().size() == SafeInt(indices_elements) * sizeof(int64_t), - "Sparse tensor: ", name, " indices raw data size does not match expected: ", - indices_elements * sizeof(int64_t)); + // For inline raw_data, validate size before unpacking to avoid a large allocation from a + // malformed tensor with small indices shape but oversized raw_data. For external data, + // raw_data is empty so we can only validate after unpacking. + if (!utils::HasExternalData(indices)) { + ORT_RETURN_IF_NOT(indices.raw_data().size() == SafeInt(indices_elements) * sizeof(int64_t), + "Sparse tensor: ", name, " indices raw data size does not match expected: ", + indices_elements * sizeof(int64_t)); + } ORT_RETURN_IF_ERROR(UnpackInitializerData(indices, model_path, unpack_buffer)); + ORT_RETURN_IF_NOT(unpack_buffer.size() == SafeInt(indices_elements) * sizeof(int64_t), + "Sparse tensor: ", name, " indices data size does not match expected: ", + indices_elements * sizeof(int64_t)); indices_data = ReinterpretAsSpan(gsl::make_span(unpack_buffer)); } else { ORT_RETURN_IF_NOT(indices.int64_data_size() == indices_elements, @@ -2129,10 +2164,15 @@ static Status CopySparseData(const std::string& name, break; case ONNX_NAMESPACE::TensorProto_DataType_INT32: { if (needs_unpack) { - ORT_RETURN_IF_NOT(indices.raw_data().size() == SafeInt(indices_elements) * sizeof(int32_t), - "Sparse tensor: ", name, " indices raw data size does not match expected: ", - indices_elements * sizeof(int32_t)); + if (!utils::HasExternalData(indices)) { + ORT_RETURN_IF_NOT(indices.raw_data().size() == SafeInt(indices_elements) * sizeof(int32_t), + "Sparse tensor: ", name, " indices raw data size does not match expected: ", + indices_elements * sizeof(int32_t)); + } ORT_RETURN_IF_ERROR(UnpackInitializerData(indices, model_path, unpack_buffer)); + ORT_RETURN_IF_NOT(unpack_buffer.size() == SafeInt(indices_elements) * sizeof(int32_t), + "Sparse tensor: ", name, " indices data size does not match expected: ", + indices_elements * sizeof(int32_t)); auto int32_span = ReinterpretAsSpan(gsl::make_span(unpack_buffer)); indices_values.insert(indices_values.cend(), int32_span.begin(), int32_span.end()); unpack_buffer.clear(); @@ -2148,10 +2188,15 @@ static Status CopySparseData(const std::string& name, } case ONNX_NAMESPACE::TensorProto_DataType_INT16: { if (needs_unpack) { - ORT_RETURN_IF_NOT(indices.raw_data().size() == SafeInt(indices_elements) * sizeof(int16_t), - "Sparse tensor: ", name, " indices raw data size does not match expected: ", - indices_elements * sizeof(int16_t)); + if (!utils::HasExternalData(indices)) { + ORT_RETURN_IF_NOT(indices.raw_data().size() == SafeInt(indices_elements) * sizeof(int16_t), + "Sparse tensor: ", name, " indices raw data size does not match expected: ", + indices_elements * sizeof(int16_t)); + } ORT_RETURN_IF_ERROR(UnpackInitializerData(indices, model_path, unpack_buffer)); + ORT_RETURN_IF_NOT(unpack_buffer.size() == SafeInt(indices_elements) * sizeof(int16_t), + "Sparse tensor: ", name, " indices data size does not match expected: ", + indices_elements * sizeof(int16_t)); auto int16_span = ReinterpretAsSpan(gsl::make_span(unpack_buffer)); indices_values.insert(indices_values.cend(), int16_span.begin(), int16_span.end()); unpack_buffer.clear(); @@ -2167,10 +2212,15 @@ static Status CopySparseData(const std::string& name, } case ONNX_NAMESPACE::TensorProto_DataType_INT8: { if (needs_unpack) { - ORT_RETURN_IF_NOT(indices.raw_data().size() == narrow(indices_elements), - "Sparse tensor: ", name, " indices raw data size does not match expected: ", - indices_elements * sizeof(int8_t)); + if (!utils::HasExternalData(indices)) { + ORT_RETURN_IF_NOT(indices.raw_data().size() == narrow(indices_elements), + "Sparse tensor: ", name, " indices raw data size does not match expected: ", + indices_elements * sizeof(int8_t)); + } ORT_RETURN_IF_ERROR(UnpackInitializerData(indices, model_path, unpack_buffer)); + ORT_RETURN_IF_NOT(unpack_buffer.size() == narrow(indices_elements), + "Sparse tensor: ", name, " indices data size does not match expected: ", + indices_elements * sizeof(int8_t)); auto int8_span = ReinterpretAsSpan(gsl::make_span(unpack_buffer)); indices_values.insert(indices_values.cend(), int8_span.begin(), int8_span.end()); unpack_buffer.clear(); @@ -2318,6 +2368,12 @@ common::Status SparseTensorProtoToDenseTensorProto(const ONNX_NAMESPACE::SparseT } } + // Validate external data paths before any early returns or allocations. + // This ensures malicious paths are rejected even for zero-element tensors, + // and prevents large allocations before an invalid path is caught. + ORT_RETURN_IF_ERROR(ValidateExternalDataPathForTensor(sparse_values, model_path)); + ORT_RETURN_IF_ERROR(ValidateExternalDataPathForTensor(indices, model_path)); + if (dense_elements == 0) { // if there are no elements in the dense tensor, we can return early with an empty tensor proto return status; diff --git a/onnxruntime/test/framework/sparse_kernels_test.cc b/onnxruntime/test/framework/sparse_kernels_test.cc index 59ec8f51b4f4e..9efaed8ac7bd6 100644 --- a/onnxruntime/test/framework/sparse_kernels_test.cc +++ b/onnxruntime/test/framework/sparse_kernels_test.cc @@ -2539,6 +2539,284 @@ TEST(SparseTensorConversionTests, SparseCooToDense_2DRowOutOfRange) { EXPECT_THAT(status.ErrorMessage(), testing::HasSubstr("Invalid COO 2D index")); } +// Positive tests for SparseTensorProtoToDenseTensorProto with external data. +// These verify end-to-end conversion succeeds when values and/or indices are stored +// in legitimate external files within the model directory. + +// Helper: write data to a temp file and configure a TensorProto to reference it as external data. +// The file is created in the current working directory using CreateTestFile. +// The ScopedFileDeleter is assigned immediately after file creation to ensure cleanup on any failure. +template +static void SetupExternalDataTensor(TensorProto_DataType type, + const std::vector& data, + PathString& filename, + TensorProto& tensor_proto, + ScopedFileDeleter& file_deleter) { + size_t size_in_bytes = data.size() * sizeof(T); + std::vector le_data(size_in_bytes); + + auto src_span = gsl::make_span(data.data(), data.size()); + auto dst_span = gsl::make_span(le_data.data(), le_data.size()); + ASSERT_STATUS_OK(onnxruntime::utils::WriteLittleEndian(src_span, dst_span)); + + FILE* fp; + CreateTestFile(fp, filename); + file_deleter = ScopedFileDeleter(filename); + ASSERT_EQ(size_in_bytes, fwrite(le_data.data(), 1, size_in_bytes, fp)); + ASSERT_EQ(0, fclose(fp)); + + tensor_proto.set_data_type(type); + tensor_proto.set_data_location(TensorProto_DataLocation_EXTERNAL); + + auto* loc = tensor_proto.mutable_external_data()->Add(); + loc->set_key("location"); + loc->set_value(ToUTF8String(filename)); + + auto* len = tensor_proto.mutable_external_data()->Add(); + len->set_key("length"); + len->set_value(std::to_string(size_in_bytes)); +} + +// External values + inline indices (INT64), rank-1 COO. +TEST(SparseTensorConversionTests, SparseTensorProtoToDense_ExternalValues_InlineIndices) { + // Dense shape [2, 3] = 6 elements. + // NNZ=3 values at linear indices [0, 2, 5]. + // Expected dense: [1.0, 0, 2.0, 0, 0, 3.0] + std::vector values = {1.0f, 2.0f, 3.0f}; + PathString values_file(ORT_TSTR("ext_val_XXXXXX")); + + SparseTensorProto sparse; + sparse.add_dims(2); + sparse.add_dims(3); + + ScopedFileDeleter values_deleter; + SetupExternalDataTensor(TensorProto_DataType_FLOAT, values, values_file, *sparse.mutable_values(), + values_deleter); + sparse.mutable_values()->set_name("ext_values_test"); + sparse.mutable_values()->add_dims(3); // NNZ + + auto* indices = sparse.mutable_indices(); + indices->set_data_type(TensorProto_DataType_INT64); + indices->add_dims(3); + indices->add_int64_data(0); + indices->add_int64_data(2); + indices->add_int64_data(5); + + // model_path in CWD so external files are within the model directory + std::filesystem::path model_path = std::filesystem::current_path() / "model.onnx"; + TensorProto dense; + ASSERT_STATUS_OK(utils::SparseTensorProtoToDenseTensorProto(sparse, model_path, dense)); + + ASSERT_EQ(dense.dims_size(), 2); + EXPECT_EQ(dense.dims(0), 2); + EXPECT_EQ(dense.dims(1), 3); + + std::vector unpacked(6); + ASSERT_STATUS_OK(utils::UnpackTensor(dense, model_path, unpacked.data(), unpacked.size())); + std::vector expected = {1.0f, 0.0f, 2.0f, 0.0f, 0.0f, 3.0f}; + EXPECT_EQ(unpacked, expected); +} + +// Inline values + external indices (INT64), rank-1 COO. +TEST(SparseTensorConversionTests, SparseTensorProtoToDense_InlineValues_ExternalIndicesInt64) { + // Dense shape [4] = 4 elements. + // NNZ=2 at indices [1, 3]. + // Expected dense: [0, 10.0, 0, 20.0] + std::vector indices_data = {1, 3}; + PathString indices_file(ORT_TSTR("ext_idx_XXXXXX")); + + SparseTensorProto sparse; + sparse.add_dims(4); + + auto* values = sparse.mutable_values(); + values->set_name("ext_indices_test"); + values->set_data_type(TensorProto_DataType_FLOAT); + values->add_dims(2); + values->add_float_data(10.0f); + values->add_float_data(20.0f); + + ScopedFileDeleter indices_deleter; + SetupExternalDataTensor(TensorProto_DataType_INT64, indices_data, indices_file, + *sparse.mutable_indices(), indices_deleter); + sparse.mutable_indices()->add_dims(2); + + std::filesystem::path model_path = std::filesystem::current_path() / "model.onnx"; + TensorProto dense; + ASSERT_STATUS_OK(utils::SparseTensorProtoToDenseTensorProto(sparse, model_path, dense)); + + std::vector unpacked(4); + ASSERT_STATUS_OK(utils::UnpackTensor(dense, model_path, unpacked.data(), unpacked.size())); + std::vector expected = {0.0f, 10.0f, 0.0f, 20.0f}; + EXPECT_EQ(unpacked, expected); +} + +// Inline values + external indices (INT32), rank-1 COO. +TEST(SparseTensorConversionTests, SparseTensorProtoToDense_InlineValues_ExternalIndicesInt32) { + std::vector indices_data = {0, 3}; + PathString indices_file(ORT_TSTR("ext_i32_XXXXXX")); + + SparseTensorProto sparse; + sparse.add_dims(2); + sparse.add_dims(2); + + auto* values = sparse.mutable_values(); + values->set_name("ext_int32_idx_test"); + values->set_data_type(TensorProto_DataType_FLOAT); + values->add_dims(2); + values->add_float_data(5.0f); + values->add_float_data(6.0f); + + ScopedFileDeleter indices_deleter; + SetupExternalDataTensor(TensorProto_DataType_INT32, indices_data, indices_file, + *sparse.mutable_indices(), indices_deleter); + sparse.mutable_indices()->add_dims(2); + + std::filesystem::path model_path = std::filesystem::current_path() / "model.onnx"; + TensorProto dense; + ASSERT_STATUS_OK(utils::SparseTensorProtoToDenseTensorProto(sparse, model_path, dense)); + + std::vector unpacked(4); + ASSERT_STATUS_OK(utils::UnpackTensor(dense, model_path, unpacked.data(), unpacked.size())); + std::vector expected = {5.0f, 0.0f, 0.0f, 6.0f}; + EXPECT_EQ(unpacked, expected); +} + +// Inline values + external indices (INT16), rank-1 COO. +TEST(SparseTensorConversionTests, SparseTensorProtoToDense_InlineValues_ExternalIndicesInt16) { + std::vector indices_data = {1, 2}; + PathString indices_file(ORT_TSTR("ext_i16_XXXXXX")); + + SparseTensorProto sparse; + sparse.add_dims(4); + + auto* values = sparse.mutable_values(); + values->set_name("ext_int16_idx_test"); + values->set_data_type(TensorProto_DataType_FLOAT); + values->add_dims(2); + values->add_float_data(7.0f); + values->add_float_data(8.0f); + + ScopedFileDeleter indices_deleter; + SetupExternalDataTensor(TensorProto_DataType_INT16, indices_data, indices_file, + *sparse.mutable_indices(), indices_deleter); + sparse.mutable_indices()->add_dims(2); + + std::filesystem::path model_path = std::filesystem::current_path() / "model.onnx"; + TensorProto dense; + ASSERT_STATUS_OK(utils::SparseTensorProtoToDenseTensorProto(sparse, model_path, dense)); + + std::vector unpacked(4); + ASSERT_STATUS_OK(utils::UnpackTensor(dense, model_path, unpacked.data(), unpacked.size())); + std::vector expected = {0.0f, 7.0f, 8.0f, 0.0f}; + EXPECT_EQ(unpacked, expected); +} + +// Inline values + external indices (INT8), rank-1 COO. +TEST(SparseTensorConversionTests, SparseTensorProtoToDense_InlineValues_ExternalIndicesInt8) { + std::vector indices_data = {0, 2}; + PathString indices_file(ORT_TSTR("ext_i8_XXXXXX")); + + SparseTensorProto sparse; + sparse.add_dims(3); + + auto* values = sparse.mutable_values(); + values->set_name("ext_int8_idx_test"); + values->set_data_type(TensorProto_DataType_FLOAT); + values->add_dims(2); + values->add_float_data(9.0f); + values->add_float_data(11.0f); + + ScopedFileDeleter indices_deleter; + SetupExternalDataTensor(TensorProto_DataType_INT8, indices_data, indices_file, + *sparse.mutable_indices(), indices_deleter); + sparse.mutable_indices()->add_dims(2); + + std::filesystem::path model_path = std::filesystem::current_path() / "model.onnx"; + TensorProto dense; + ASSERT_STATUS_OK(utils::SparseTensorProtoToDenseTensorProto(sparse, model_path, dense)); + + std::vector unpacked(3); + ASSERT_STATUS_OK(utils::UnpackTensor(dense, model_path, unpacked.data(), unpacked.size())); + std::vector expected = {9.0f, 0.0f, 11.0f}; + EXPECT_EQ(unpacked, expected); +} + +// Both external values and external indices (INT64), rank-1 COO. +TEST(SparseTensorConversionTests, SparseTensorProtoToDense_ExternalValues_ExternalIndicesInt64) { + // Dense shape [3, 2] = 6 elements. + // NNZ=2 at linear indices [1, 4]. + // Expected dense: [0, 100.0, 0, 0, 200.0, 0] + std::vector values_data = {100.0f, 200.0f}; + std::vector indices_data = {1, 4}; + PathString values_file(ORT_TSTR("ext_bv_XXXXXX")); + PathString indices_file(ORT_TSTR("ext_bi_XXXXXX")); + + SparseTensorProto sparse; + sparse.add_dims(3); + sparse.add_dims(2); + + ScopedFileDeleter values_deleter; + SetupExternalDataTensor(TensorProto_DataType_FLOAT, values_data, values_file, *sparse.mutable_values(), + values_deleter); + sparse.mutable_values()->set_name("ext_both_test"); + sparse.mutable_values()->add_dims(2); + + ScopedFileDeleter indices_deleter; + SetupExternalDataTensor(TensorProto_DataType_INT64, indices_data, indices_file, + *sparse.mutable_indices(), indices_deleter); + sparse.mutable_indices()->add_dims(2); + + std::filesystem::path model_path = std::filesystem::current_path() / "model.onnx"; + TensorProto dense; + ASSERT_STATUS_OK(utils::SparseTensorProtoToDenseTensorProto(sparse, model_path, dense)); + + ASSERT_EQ(dense.dims_size(), 2); + EXPECT_EQ(dense.dims(0), 3); + EXPECT_EQ(dense.dims(1), 2); + + std::vector unpacked(6); + ASSERT_STATUS_OK(utils::UnpackTensor(dense, model_path, unpacked.data(), unpacked.size())); + std::vector expected = {0.0f, 100.0f, 0.0f, 0.0f, 200.0f, 0.0f}; + EXPECT_EQ(unpacked, expected); +} + +// Both external values and external indices (INT64), rank-2 COO indices. +TEST(SparseTensorConversionTests, SparseTensorProtoToDense_ExternalValues_ExternalIndicesInt64_Rank2) { + // Dense shape [3, 3] = 9 elements. + // NNZ=2 with 2D indices: [[0, 2], [2, 0]] -> positions (0,2)=2, (2,0)=6. + // Expected dense: [0, 0, 50.0, 0, 0, 0, 60.0, 0, 0] + std::vector values_data = {50.0f, 60.0f}; + // Rank-2 indices: flattened as [row0, col0, row1, col1] + std::vector indices_data = {0, 2, 2, 0}; + PathString values_file(ORT_TSTR("ext_r2v_XXXXXX")); + PathString indices_file(ORT_TSTR("ext_r2i_XXXXXX")); + + SparseTensorProto sparse; + sparse.add_dims(3); + sparse.add_dims(3); + + ScopedFileDeleter values_deleter; + SetupExternalDataTensor(TensorProto_DataType_FLOAT, values_data, values_file, *sparse.mutable_values(), + values_deleter); + sparse.mutable_values()->set_name("ext_rank2_test"); + sparse.mutable_values()->add_dims(2); // NNZ + + ScopedFileDeleter indices_deleter; + SetupExternalDataTensor(TensorProto_DataType_INT64, indices_data, indices_file, + *sparse.mutable_indices(), indices_deleter); + sparse.mutable_indices()->add_dims(2); // NNZ + sparse.mutable_indices()->add_dims(2); // rank of dense tensor + + std::filesystem::path model_path = std::filesystem::current_path() / "model.onnx"; + TensorProto dense; + ASSERT_STATUS_OK(utils::SparseTensorProtoToDenseTensorProto(sparse, model_path, dense)); + + std::vector unpacked(9); + ASSERT_STATUS_OK(utils::UnpackTensor(dense, model_path, unpacked.data(), unpacked.size())); + std::vector expected = {0.0f, 0.0f, 50.0f, 0.0f, 0.0f, 0.0f, 60.0f, 0.0f, 0.0f}; + EXPECT_EQ(unpacked, expected); +} + #endif // !defined(DISABLE_SPARSE_TENSORS) } // namespace test } // namespace onnxruntime diff --git a/onnxruntime/test/framework/tensorutils_test.cc b/onnxruntime/test/framework/tensorutils_test.cc index 71ac5b49e9718..06cc3ea6ad8d2 100644 --- a/onnxruntime/test/framework/tensorutils_test.cc +++ b/onnxruntime/test/framework/tensorutils_test.cc @@ -835,6 +835,324 @@ TEST_F(PathValidationTest, WeaklyCanonicalPathNtVolumeFallback_ResolvesDotDot) { } #endif // defined(_WIN32) +#if !defined(DISABLE_SPARSE_TENSORS) +// Regression test: SparseTensorProtoToDenseTensorProto must reject external_data paths +// that escape the model directory (path traversal via "../" in location). +TEST_F(PathValidationTest, SparseTensorExternalDataPathTraversalBlocked_Values) { + // Create model directory and a "secret" file outside it. + auto model_dir = base_dir_ / "model_dir"; + std::error_code ec; + std::filesystem::create_directories(model_dir, ec); + ASSERT_FALSE(ec) << "Failed to create model_dir: " << ec.message(); + + // Write known float data to a file outside the model directory. + auto secret_file = base_dir_ / "secret.bin"; + { + std::ofstream ofs(secret_file, std::ios::binary); + ASSERT_TRUE(ofs.is_open()) << "Failed to open " << secret_file; + float secret_data[] = {42.0f, 99.0f}; + ofs.write(reinterpret_cast(secret_data), sizeof(secret_data)); + ASSERT_TRUE(ofs.good()) << "Failed to write to " << secret_file; + } + + // Construct a SparseTensorProto whose values use external data with a path-traversal location. + ONNX_NAMESPACE::SparseTensorProto sparse; + sparse.add_dims(4); // dense shape: [4] + + // Values tensor: 2 non-zero float values stored in external file. + auto* values = sparse.mutable_values(); + values->set_name("sparse_test"); + values->set_data_type(ONNX_NAMESPACE::TensorProto_DataType_FLOAT); + values->add_dims(2); // 2 non-zero elements + values->set_data_location(ONNX_NAMESPACE::TensorProto_DataLocation_EXTERNAL); + + auto* loc = values->add_external_data(); + loc->set_key("location"); + loc->set_value("../secret.bin"); // path traversal! + + auto* len_entry = values->add_external_data(); + len_entry->set_key("length"); + len_entry->set_value(std::to_string(2 * sizeof(float))); + + // Indices: positions 0 and 1 in the dense tensor. + auto* indices = sparse.mutable_indices(); + indices->set_data_type(ONNX_NAMESPACE::TensorProto_DataType_INT64); + indices->add_dims(2); + indices->add_int64_data(0); + indices->add_int64_data(1); + + // Attempt to convert — this should fail with a path validation error. + ONNX_NAMESPACE::TensorProto dense; + std::filesystem::path model_path = model_dir / "model.onnx"; + Status status = utils::SparseTensorProtoToDenseTensorProto(sparse, model_path, dense); + ASSERT_FALSE(status.IsOK()) << "SparseTensorProtoToDenseTensorProto should reject path-traversal " + "in values external_data location, but it succeeded (reading " + "arbitrary file outside model directory)."; + EXPECT_THAT(status.ErrorMessage(), ::testing::HasSubstr("escapes")); +} + +// Same as above but for path traversal in the indices external data. +TEST_F(PathValidationTest, SparseTensorExternalDataPathTraversalBlocked_Indices) { + auto model_dir = base_dir_ / "model_dir"; + std::error_code ec; + std::filesystem::create_directories(model_dir, ec); + ASSERT_FALSE(ec) << "Failed to create model_dir: " << ec.message(); + + // Write indices data (2 x int64) to a file outside the model directory. + auto secret_file = base_dir_ / "indices_secret.bin"; + { + std::ofstream ofs(secret_file, std::ios::binary); + ASSERT_TRUE(ofs.is_open()) << "Failed to open " << secret_file; + int64_t idx_data[] = {0, 1}; + ofs.write(reinterpret_cast(idx_data), sizeof(idx_data)); + ASSERT_TRUE(ofs.good()) << "Failed to write to " << secret_file; + } + + // Also need a valid values file inside the model directory. + auto values_file = model_dir / "values.bin"; + { + std::ofstream ofs(values_file, std::ios::binary); + ASSERT_TRUE(ofs.is_open()) << "Failed to open " << values_file; + float val_data[] = {1.0f, 2.0f}; + ofs.write(reinterpret_cast(val_data), sizeof(val_data)); + ASSERT_TRUE(ofs.good()) << "Failed to write to " << values_file; + } + + ONNX_NAMESPACE::SparseTensorProto sparse; + sparse.add_dims(4); + + // Values: legitimate external data within model directory. + auto* values = sparse.mutable_values(); + values->set_name("sparse_idx_test"); + values->set_data_type(ONNX_NAMESPACE::TensorProto_DataType_FLOAT); + values->add_dims(2); + values->set_data_location(ONNX_NAMESPACE::TensorProto_DataLocation_EXTERNAL); + + auto* val_loc = values->add_external_data(); + val_loc->set_key("location"); + val_loc->set_value("values.bin"); + + auto* val_len = values->add_external_data(); + val_len->set_key("length"); + val_len->set_value(std::to_string(2 * sizeof(float))); + + // Indices: external data with path traversal. + auto* indices = sparse.mutable_indices(); + indices->set_data_type(ONNX_NAMESPACE::TensorProto_DataType_INT64); + indices->add_dims(2); + indices->set_data_location(ONNX_NAMESPACE::TensorProto_DataLocation_EXTERNAL); + + auto* idx_loc = indices->add_external_data(); + idx_loc->set_key("location"); + idx_loc->set_value("../indices_secret.bin"); // path traversal! + + auto* idx_len = indices->add_external_data(); + idx_len->set_key("length"); + idx_len->set_value(std::to_string(2 * sizeof(int64_t))); + + ONNX_NAMESPACE::TensorProto dense; + std::filesystem::path model_path = model_dir / "model.onnx"; + Status status = utils::SparseTensorProtoToDenseTensorProto(sparse, model_path, dense); + ASSERT_FALSE(status.IsOK()) << "SparseTensorProtoToDenseTensorProto should reject path-traversal " + "in indices external_data location, but it succeeded."; + EXPECT_THAT(status.ErrorMessage(), ::testing::HasSubstr("escapes")); +} + +// Regression test: SparseTensorProtoToDenseTensorProto must reject absolute paths +// in values external_data location. +TEST_F(PathValidationTest, SparseTensorExternalDataAbsolutePathBlocked_Values) { + ONNX_NAMESPACE::SparseTensorProto sparse; + sparse.add_dims(4); + + auto* values = sparse.mutable_values(); + values->set_name("abs_path_test"); + values->set_data_type(ONNX_NAMESPACE::TensorProto_DataType_FLOAT); + values->add_dims(2); + values->set_data_location(ONNX_NAMESPACE::TensorProto_DataLocation_EXTERNAL); + + auto* loc = values->add_external_data(); + loc->set_key("location"); + loc->set_value("/data.bin"); // absolute path + + auto* len_entry = values->add_external_data(); + len_entry->set_key("length"); + len_entry->set_value(std::to_string(2 * sizeof(float))); + + auto* indices = sparse.mutable_indices(); + indices->set_data_type(ONNX_NAMESPACE::TensorProto_DataType_INT64); + indices->add_dims(2); + indices->add_int64_data(0); + indices->add_int64_data(1); + + ONNX_NAMESPACE::TensorProto dense; + std::filesystem::path model_path = base_dir_ / "model.onnx"; + Status status = utils::SparseTensorProtoToDenseTensorProto(sparse, model_path, dense); + ASSERT_FALSE(status.IsOK()) << "SparseTensorProtoToDenseTensorProto should reject absolute path " + "in values external_data location."; + EXPECT_THAT(status.ErrorMessage(), ::testing::HasSubstr("Absolute path not allowed")); + +#ifdef _WIN32 + // Also verify Windows-style absolute path. + loc->set_value("C:\\data.bin"); + status = utils::SparseTensorProtoToDenseTensorProto(sparse, model_path, dense); + ASSERT_FALSE(status.IsOK()) << "SparseTensorProtoToDenseTensorProto should reject Windows absolute path " + "in values external_data location."; + EXPECT_THAT(status.ErrorMessage(), ::testing::HasSubstr("Absolute path not allowed")); +#endif +} + +// Regression test: SparseTensorProtoToDenseTensorProto must reject absolute paths +// in indices external_data location. +TEST_F(PathValidationTest, SparseTensorExternalDataAbsolutePathBlocked_Indices) { + // Create a valid values file inside base_dir_ so values validation passes. + auto values_file = base_dir_ / "values.bin"; + { + std::ofstream ofs(values_file, std::ios::binary); + ASSERT_TRUE(ofs.is_open()) << "Failed to open " << values_file; + float val_data[] = {1.0f, 2.0f}; + ofs.write(reinterpret_cast(val_data), sizeof(val_data)); + ASSERT_TRUE(ofs.good()) << "Failed to write to " << values_file; + } + + ONNX_NAMESPACE::SparseTensorProto sparse; + sparse.add_dims(4); + + // Values: legitimate external data within base_dir_. + auto* values = sparse.mutable_values(); + values->set_name("abs_path_idx_test"); + values->set_data_type(ONNX_NAMESPACE::TensorProto_DataType_FLOAT); + values->add_dims(2); + values->set_data_location(ONNX_NAMESPACE::TensorProto_DataLocation_EXTERNAL); + + auto* val_loc = values->add_external_data(); + val_loc->set_key("location"); + val_loc->set_value("values.bin"); + + auto* val_len = values->add_external_data(); + val_len->set_key("length"); + val_len->set_value(std::to_string(2 * sizeof(float))); + + // Indices: external data with absolute path. + auto* indices = sparse.mutable_indices(); + indices->set_data_type(ONNX_NAMESPACE::TensorProto_DataType_INT64); + indices->add_dims(2); + indices->set_data_location(ONNX_NAMESPACE::TensorProto_DataLocation_EXTERNAL); + + auto* idx_loc = indices->add_external_data(); + idx_loc->set_key("location"); + idx_loc->set_value("/data.bin"); // absolute path + + auto* idx_len = indices->add_external_data(); + idx_len->set_key("length"); + idx_len->set_value(std::to_string(2 * sizeof(int64_t))); + + ONNX_NAMESPACE::TensorProto dense; + std::filesystem::path model_path = base_dir_ / "model.onnx"; + Status status = utils::SparseTensorProtoToDenseTensorProto(sparse, model_path, dense); + ASSERT_FALSE(status.IsOK()) << "SparseTensorProtoToDenseTensorProto should reject absolute path " + "in indices external_data location."; + EXPECT_THAT(status.ErrorMessage(), ::testing::HasSubstr("Absolute path not allowed")); + +#ifdef _WIN32 + idx_loc->set_value("C:\\data.bin"); + status = utils::SparseTensorProtoToDenseTensorProto(sparse, model_path, dense); + ASSERT_FALSE(status.IsOK()) << "SparseTensorProtoToDenseTensorProto should reject Windows absolute path " + "in indices external_data location."; + EXPECT_THAT(status.ErrorMessage(), ::testing::HasSubstr("Absolute path not allowed")); +#endif +} + +// Regression test: validation must still reject escaping paths for zero-element dense tensors, +// which previously returned early before path validation ran. +TEST_F(PathValidationTest, SparseTensorExternalDataPathTraversalBlocked_ZeroDenseElements) { + auto model_dir = base_dir_ / "model_dir"; + std::error_code ec; + std::filesystem::create_directories(model_dir, ec); + ASSERT_FALSE(ec) << "Failed to create model_dir: " << ec.message(); + + // Create the escaping file so that a "file not found" error would NOT be raised. + auto secret_file = base_dir_ / "secret.bin"; + { + std::ofstream ofs(secret_file, std::ios::binary); + ASSERT_TRUE(ofs.is_open()) << "Failed to open " << secret_file; + ofs.put('\0'); + ASSERT_TRUE(ofs.good()) << "Failed to write to " << secret_file; + } + + ONNX_NAMESPACE::SparseTensorProto sparse; + sparse.add_dims(0); // dense shape [0] → dense_elements == 0 + + auto* values = sparse.mutable_values(); + values->set_name("zero_dense_test"); + values->set_data_type(ONNX_NAMESPACE::TensorProto_DataType_FLOAT); + values->add_dims(0); // NNZ=0 + values->set_data_location(ONNX_NAMESPACE::TensorProto_DataLocation_EXTERNAL); + + auto* loc = values->add_external_data(); + loc->set_key("location"); + loc->set_value("../secret.bin"); // path traversal + + auto* len_entry = values->add_external_data(); + len_entry->set_key("length"); + len_entry->set_value("0"); + + auto* indices = sparse.mutable_indices(); + indices->set_data_type(ONNX_NAMESPACE::TensorProto_DataType_INT64); + indices->add_dims(0); + + ONNX_NAMESPACE::TensorProto dense; + std::filesystem::path model_path = model_dir / "model.onnx"; + Status status = utils::SparseTensorProtoToDenseTensorProto(sparse, model_path, dense); + ASSERT_FALSE(status.IsOK()) << "Should reject path-traversal in values even when dense_elements == 0."; + EXPECT_THAT(status.ErrorMessage(), ::testing::HasSubstr("escapes")); +} + +// Regression test: validation must reject escaping paths in indices even when NNZ == 0. +TEST_F(PathValidationTest, SparseTensorExternalDataPathTraversalBlocked_ZeroNNZ) { + auto model_dir = base_dir_ / "model_dir"; + std::error_code ec; + std::filesystem::create_directories(model_dir, ec); + ASSERT_FALSE(ec) << "Failed to create model_dir: " << ec.message(); + + // Create the escaping file so that a "file not found" error would NOT be raised. + auto secret_file = base_dir_ / "indices_secret.bin"; + { + std::ofstream ofs(secret_file, std::ios::binary); + ASSERT_TRUE(ofs.is_open()) << "Failed to open " << secret_file; + ofs.put('\0'); + ASSERT_TRUE(ofs.good()) << "Failed to write to " << secret_file; + } + + ONNX_NAMESPACE::SparseTensorProto sparse; + sparse.add_dims(4); // dense shape [4] → non-zero dense_elements + + auto* values = sparse.mutable_values(); + values->set_name("zero_nnz_test"); + values->set_data_type(ONNX_NAMESPACE::TensorProto_DataType_FLOAT); + values->add_dims(0); // NNZ=0 + + auto* indices = sparse.mutable_indices(); + indices->set_data_type(ONNX_NAMESPACE::TensorProto_DataType_INT64); + indices->add_dims(0); + indices->set_data_location(ONNX_NAMESPACE::TensorProto_DataLocation_EXTERNAL); + + auto* idx_loc = indices->add_external_data(); + idx_loc->set_key("location"); + idx_loc->set_value("../indices_secret.bin"); // path traversal + + auto* idx_len = indices->add_external_data(); + idx_len->set_key("length"); + idx_len->set_value("0"); + + ONNX_NAMESPACE::TensorProto dense; + std::filesystem::path model_path = model_dir / "model.onnx"; + Status status = utils::SparseTensorProtoToDenseTensorProto(sparse, model_path, dense); + ASSERT_FALSE(status.IsOK()) << "Should reject path-traversal in indices even when NNZ == 0."; + EXPECT_THAT(status.ErrorMessage(), ::testing::HasSubstr("escapes")); +} + +#endif // !defined(DISABLE_SPARSE_TENSORS) + TEST(TensorProtoUtilsTest, GetNodeProtoLayeringAnnotation) { // Case 1: Annotation exists {