Skip to content

Commit ab624f7

Browse files
committed
fix(vxcore): reject reparse points and fail closed in ImportFolder/CopyFolder
The recursive copy_filtered walkers used entry.is_directory()/is_regular_file(), which follow reparse points, so a symlink or junction inside an external tree could point back into the notebook root (circular copy) or at arbitrary out-of-tree data (exfiltration into the notebook). The top-level containment guard only WARN-logged when weakly_canonical threw, silently disabling itself. fs::is_symlink() is not sufficient on Windows: MSVC maps IO_REPARSE_TAG_MOUNT_POINT to the implementation-defined file_type::junction, which is_symlink() does not report. Detection is therefore attribute-based (FILE_ATTRIBUTE_REPARSE_POINT) on Windows. - Add IsReparsePoint / CheckReparsePoint (tri-state), IsPathWithin / IsPathWithinCanonical, and a shared CopyTreeSkipReparsePoints walker to file_utils. The walker is a hard exception boundary so callers keep returning VXCORE_ERR_IO, and reports a truncated copy as a failure. - Make the notebook-root guard fail closed in both managers: unverifiable containment now returns VXCORE_ERR_INVALID_PARAM instead of proceeding. - Skip reparse points and out-of-source-root subdirectories in both ImportFolder walkers; abort with VXCORE_ERR_IO when an entry cannot be stat'ed rather than silently truncating the import. - Replace fs::copy(recursive) in both CopyFolder implementations, and prune index entries whose content was skipped so the copied vx.json cannot reference a path that does not exist on disk. TOCTOU is out of scope and documented on the walkers.
1 parent 7ac4afa commit ab624f7

7 files changed

Lines changed: 1108 additions & 57 deletions

File tree

src/core/bundled_folder_manager.cpp

Lines changed: 100 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -847,6 +847,40 @@ VxCoreError BundledFolderManager::ProcessCopiedFolderTree(const std::string &des
847847
const std::string &assets_folder_name = notebook_->GetConfig().assets_folder;
848848
ContentProcessor processor;
849849

850+
// Drop index entries whose content did not make it to the destination. The
851+
// content tree is copied by CopyTreeSkipReparsePoints, which SKIPS symlinks /
852+
// junctions; the config subtree is copied verbatim, so without this pruning a
853+
// symlinked-but-indexed entry would leave the copied vx.json referencing a
854+
// path that does not exist on disk (the phantom state CopyFolder reports
855+
// elsewhere as VXCORE_ERR_NODE_NOT_EXISTS) while still returning VXCORE_OK.
856+
config->files.erase(
857+
std::remove_if(config->files.begin(), config->files.end(),
858+
[&](const FileRecord &file) {
859+
if (PathExists(ConcatenatePaths(content_path, file.name))) {
860+
return false;
861+
}
862+
VXCORE_LOG_WARN(
863+
"ProcessCopiedFolderTree: dropping index entry with no copied "
864+
"content: %s/%s",
865+
dest_path.c_str(), file.name.c_str());
866+
return true;
867+
}),
868+
config->files.end());
869+
870+
config->folders.erase(
871+
std::remove_if(config->folders.begin(), config->folders.end(),
872+
[&](const std::string &subfolder_name) {
873+
if (IsDirectory(ConcatenatePaths(content_path, subfolder_name))) {
874+
return false;
875+
}
876+
VXCORE_LOG_WARN(
877+
"ProcessCopiedFolderTree: dropping subfolder entry with no copied "
878+
"content: %s/%s",
879+
dest_path.c_str(), subfolder_name.c_str());
880+
return true;
881+
}),
882+
config->folders.end());
883+
850884
// Process each file: regenerate UUID, rename assets dir, rewrite content
851885
for (auto &file : config->files) {
852886
std::string old_uuid = file.id;
@@ -987,9 +1021,11 @@ VxCoreError BundledFolderManager::CopyFolder(const std::string &src_path,
9871021
return error;
9881022
}
9891023

990-
try {
991-
fs::copy(src_content_path_fs, dest_content_path_fs, fs::copy_options::recursive);
992-
} catch (const std::exception &) {
1024+
// Recursive copy that never follows symlinks/junctions and never escapes the
1025+
// source tree. fs::copy(recursive) follows reparse points; copy_options::
1026+
// skip_symlinks is also insufficient on MSVC, which classifies junctions as
1027+
// file_type::junction rather than file_type::symlink.
1028+
if (!CopyTreeSkipReparsePoints(src_content_path_fs, dest_content_path_fs)) {
9931029
return VXCORE_ERR_IO;
9941030
}
9951031

@@ -998,7 +1034,9 @@ VxCoreError BundledFolderManager::CopyFolder(const std::string &src_path,
9981034
try {
9991035
fs::path src_config_dir = PathFromUtf8(GetConfigPath(clean_src_path)).parent_path();
10001036
fs::path dest_config_dir = PathFromUtf8(GetConfigPath(dest_path)).parent_path();
1001-
fs::copy(src_config_dir, dest_config_dir, fs::copy_options::recursive);
1037+
if (!CopyTreeSkipReparsePoints(src_config_dir, dest_config_dir)) {
1038+
return VXCORE_ERR_IO;
1039+
}
10021040
} catch (const std::exception &) {
10031041
return VXCORE_ERR_IO;
10041042
}
@@ -2282,28 +2320,24 @@ VxCoreError BundledFolderManager::ImportFolder(const std::string &dest_folder_pa
22822320
return VXCORE_ERR_INVALID_PARAM;
22832321
}
22842322

2285-
// Reject importing a folder from within the notebook root to prevent circular copies
2286-
try {
2287-
// BOTH sides must be canonicalized before comparing. Canonicalizing only
2288-
// the external path made the check silently pass whenever the two spellings
2289-
// of the same location differ — e.g. on Windows where the notebook root was
2290-
// opened through an 8.3 short path (C:/Users/RUNNER~1/...) while
2291-
// fs::canonical resolves the external path to the long form
2292-
// (C:/Users/runneradmin/...), or anywhere a symlink is in play.
2293-
fs::path canonical_external = fs::weakly_canonical(external_path);
2294-
fs::path canonical_root = fs::weakly_canonical(PathFromUtf8(notebook_->GetRootFolder()));
2295-
// Check if external path starts with (is under) notebook root
2296-
auto mismatch_pair = std::mismatch(canonical_root.begin(), canonical_root.end(),
2297-
canonical_external.begin(), canonical_external.end());
2298-
if (mismatch_pair.first == canonical_root.end()) {
2299-
// root_path is a prefix of canonical_external (external is inside notebook)
2300-
VXCORE_LOG_ERROR("ImportFolder: Cannot import folder from within notebook root: %s",
2301-
external_folder_path.c_str());
2302-
return VXCORE_ERR_INVALID_PARAM;
2303-
}
2304-
} catch (const std::exception &e) {
2305-
// If canonical fails, the path may not exist or be inaccessible
2306-
VXCORE_LOG_WARN("ImportFolder: Failed to canonicalize paths: %s", e.what());
2323+
// Reject importing a folder from within the notebook root to prevent circular
2324+
// copies. This fails CLOSED: if containment cannot be verified (permission
2325+
// denied, cyclic link, device error) the import is rejected rather than
2326+
// silently proceeding without a guard.
2327+
if (IsPathWithin(notebook_->GetRootFolder(), external_folder_path, /*on_error=*/true)) {
2328+
VXCORE_LOG_ERROR("ImportFolder: Cannot import folder from within notebook root "
2329+
"(or containment could not be verified): %s",
2330+
external_folder_path.c_str());
2331+
return VXCORE_ERR_INVALID_PARAM;
2332+
}
2333+
2334+
// Canonical source root, captured once, for the per-entry containment check.
2335+
std::error_code src_ec;
2336+
const fs::path canonical_source_root = fs::weakly_canonical(external_path, src_ec);
2337+
if (src_ec) {
2338+
VXCORE_LOG_ERROR("ImportFolder: Failed to canonicalize external folder: %s (%s)",
2339+
external_folder_path.c_str(), src_ec.message().c_str());
2340+
return VXCORE_ERR_INVALID_PARAM;
23072341
}
23082342

23092343
// Parse suffix allowlist into a set of lowercase extensions
@@ -2367,18 +2401,47 @@ VxCoreError BundledFolderManager::ImportFolder(const std::string &dest_folder_pa
23672401
return VXCORE_ERR_ALREADY_EXISTS;
23682402
}
23692403

2370-
// Copy the external folder to notebook folder recursively (with suffix filtering)
2371-
std::function<void(const fs::path &, const fs::path &)> copy_filtered =
2372-
[&](const fs::path &src, const fs::path &dest) {
2404+
// Copy the external folder to notebook folder recursively (with suffix filtering).
2405+
// Returns false on a hard IO error so the caller can report VXCORE_ERR_IO: an
2406+
// entry that cannot be stat'ed is NOT silently dropped, which would turn a
2407+
// truncated import into a VXCORE_OK.
2408+
//
2409+
// TOCTOU: these are path-based check-then-recurse guards. A source tree
2410+
// mutated concurrently by an attacker can still race between the check and
2411+
// the copy; closing that would need handle-relative / no-follow traversal,
2412+
// which is not attempted here.
2413+
std::function<bool(const fs::path &, const fs::path &)> copy_filtered =
2414+
[&](const fs::path &src, const fs::path &dest) -> bool {
23732415
fs::create_directories(dest);
23742416
for (const auto &entry : fs::directory_iterator(src)) {
23752417
const std::string entry_name = PathToUtf8(entry.path().filename());
23762418
// Skip hidden files/folders
23772419
if (entry_name.empty() || entry_name[0] == '.') {
23782420
continue;
23792421
}
2422+
const std::string entry_utf8 = PathToUtf8(entry.path());
2423+
// Primary defense: never follow symlinks/junctions/other reparse
2424+
// points, for files as well as directories.
2425+
const ReparseState reparse_state = CheckReparsePoint(entry_utf8);
2426+
if (reparse_state == ReparseState::kError) {
2427+
VXCORE_LOG_ERROR("ImportFolder: Failed to stat entry: %s", entry_utf8.c_str());
2428+
return false;
2429+
}
2430+
if (reparse_state == ReparseState::kYes) {
2431+
VXCORE_LOG_WARN("ImportFolder: Skipping symlink/junction/reparse point: %s",
2432+
entry_utf8.c_str());
2433+
continue;
2434+
}
23802435
if (entry.is_directory()) {
2381-
copy_filtered(entry.path(), dest / PathFromUtf8(entry_name));
2436+
// Defense in depth: cannot verify containment -> outside -> skip.
2437+
if (!IsPathWithinCanonical(canonical_source_root, entry_utf8, /*on_error=*/false)) {
2438+
VXCORE_LOG_WARN("ImportFolder: Skipping subdirectory outside source root: %s",
2439+
entry_utf8.c_str());
2440+
continue;
2441+
}
2442+
if (!copy_filtered(entry.path(), dest / PathFromUtf8(entry_name))) {
2443+
return false;
2444+
}
23822445
} else if (entry.is_regular_file()) {
23832446
// Apply suffix filter if allowlist is specified
23842447
if (!allowed_suffixes.empty()) {
@@ -2396,10 +2459,16 @@ VxCoreError BundledFolderManager::ImportFolder(const std::string &dest_folder_pa
23962459
fs::copy_options::overwrite_existing);
23972460
}
23982461
}
2462+
return true;
23992463
};
24002464

24012465
try {
2402-
copy_filtered(external_path, target_path);
2466+
if (!copy_filtered(external_path, target_path)) {
2467+
VXCORE_LOG_ERROR("ImportFolder: Failed to copy folder: source entry unreadable");
2468+
std::error_code rollback_ec;
2469+
fs::remove_all(target_path, rollback_ec);
2470+
return VXCORE_ERR_IO;
2471+
}
24032472
VXCORE_LOG_DEBUG("ImportFolder: Copied folder to: %s", PathToUtf8(target_path).c_str());
24042473
} catch (const std::exception &e) {
24052474
VXCORE_LOG_ERROR("ImportFolder: Failed to copy folder: %s", e.what());

src/core/raw_folder_manager.cpp

Lines changed: 63 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -949,10 +949,11 @@ VxCoreError RawFolderManager::CopyFolder(const std::string &src_path,
949949
return VXCORE_ERR_ALREADY_EXISTS;
950950
}
951951

952-
// Filesystem first — recursive copy
953-
try {
954-
fs::copy(src_fs, dest_fs, fs::copy_options::recursive);
955-
} catch (const std::exception &) {
952+
// Filesystem first — recursive copy that never follows symlinks/junctions and
953+
// never escapes the source tree. fs::copy(recursive) follows reparse points;
954+
// copy_options::skip_symlinks is also insufficient on MSVC, which classifies
955+
// junctions as file_type::junction rather than file_type::symlink.
956+
if (!CopyTreeSkipReparsePoints(src_fs, dest_fs)) {
956957
return VXCORE_ERR_IO;
957958
}
958959

@@ -1727,23 +1728,24 @@ VxCoreError RawFolderManager::ImportFolder(const std::string &dest_folder_path,
17271728
return VXCORE_ERR_INVALID_PARAM;
17281729
}
17291730

1730-
// Reject importing from within the notebook root
1731-
try {
1732-
// BOTH sides must be canonicalized — see the same guard in
1733-
// BundledFolderManager::ImportFolder for why comparing a raw root against a
1734-
// canonicalized external path misses containment (8.3 short paths,
1735-
// symlinks).
1736-
fs::path canonical_external = fs::weakly_canonical(external_path);
1737-
fs::path canonical_root = fs::weakly_canonical(PathFromUtf8(notebook_->GetRootFolder()));
1738-
auto mismatch_pair = std::mismatch(canonical_root.begin(), canonical_root.end(),
1739-
canonical_external.begin(), canonical_external.end());
1740-
if (mismatch_pair.first == canonical_root.end()) {
1741-
VXCORE_LOG_ERROR("ImportFolder: Cannot import folder from within notebook root: %s",
1742-
external_folder_path.c_str());
1743-
return VXCORE_ERR_INVALID_PARAM;
1744-
}
1745-
} catch (const std::exception &e) {
1746-
VXCORE_LOG_WARN("ImportFolder: Failed to canonicalize paths: %s", e.what());
1731+
// Reject importing from within the notebook root. This fails CLOSED: if
1732+
// containment cannot be verified the import is rejected rather than silently
1733+
// proceeding without a guard. See the same guard in
1734+
// BundledFolderManager::ImportFolder.
1735+
if (IsPathWithin(notebook_->GetRootFolder(), external_folder_path, /*on_error=*/true)) {
1736+
VXCORE_LOG_ERROR("ImportFolder: Cannot import folder from within notebook root "
1737+
"(or containment could not be verified): %s",
1738+
external_folder_path.c_str());
1739+
return VXCORE_ERR_INVALID_PARAM;
1740+
}
1741+
1742+
// Canonical source root, captured once, for the per-entry containment check.
1743+
std::error_code src_ec;
1744+
const fs::path canonical_source_root = fs::weakly_canonical(external_path, src_ec);
1745+
if (src_ec) {
1746+
VXCORE_LOG_ERROR("ImportFolder: Failed to canonicalize external folder: %s (%s)",
1747+
external_folder_path.c_str(), src_ec.message().c_str());
1748+
return VXCORE_ERR_INVALID_PARAM;
17471749
}
17481750

17491751
// Parse suffix allowlist
@@ -1795,18 +1797,47 @@ VxCoreError RawFolderManager::ImportFolder(const std::string &dest_folder_path,
17951797
}
17961798
}
17971799

1798-
// Recursive copy with suffix filtering (filesystem first)
1799-
std::function<void(const fs::path &, const fs::path &)> copy_filtered =
1800-
[&](const fs::path &src, const fs::path &dest) {
1800+
// Recursive copy with suffix filtering (filesystem first).
1801+
// Returns false on a hard IO error so the caller can report VXCORE_ERR_IO: an
1802+
// entry that cannot be stat'ed is NOT silently dropped, which would turn a
1803+
// truncated import into a VXCORE_OK.
1804+
//
1805+
// TOCTOU: these are path-based check-then-recurse guards. A source tree
1806+
// mutated concurrently by an attacker can still race between the check and
1807+
// the copy; closing that would need handle-relative / no-follow traversal,
1808+
// which is not attempted here.
1809+
std::function<bool(const fs::path &, const fs::path &)> copy_filtered =
1810+
[&](const fs::path &src, const fs::path &dest) -> bool {
18011811
fs::create_directories(dest);
18021812
for (const auto &entry : fs::directory_iterator(src)) {
18031813
const std::string entry_name = PathToUtf8(entry.path().filename());
18041814
// Skip hidden files/folders
18051815
if (entry_name.empty() || entry_name[0] == '.') {
18061816
continue;
18071817
}
1818+
const std::string entry_utf8 = PathToUtf8(entry.path());
1819+
// Primary defense: never follow symlinks/junctions/other reparse
1820+
// points, for files as well as directories.
1821+
const ReparseState reparse_state = CheckReparsePoint(entry_utf8);
1822+
if (reparse_state == ReparseState::kError) {
1823+
VXCORE_LOG_ERROR("ImportFolder: Failed to stat entry: %s", entry_utf8.c_str());
1824+
return false;
1825+
}
1826+
if (reparse_state == ReparseState::kYes) {
1827+
VXCORE_LOG_WARN("ImportFolder: Skipping symlink/junction/reparse point: %s",
1828+
entry_utf8.c_str());
1829+
continue;
1830+
}
18081831
if (entry.is_directory()) {
1809-
copy_filtered(entry.path(), dest / PathFromUtf8(entry_name));
1832+
// Defense in depth: cannot verify containment -> outside -> skip.
1833+
if (!IsPathWithinCanonical(canonical_source_root, entry_utf8, /*on_error=*/false)) {
1834+
VXCORE_LOG_WARN("ImportFolder: Skipping subdirectory outside source root: %s",
1835+
entry_utf8.c_str());
1836+
continue;
1837+
}
1838+
if (!copy_filtered(entry.path(), dest / PathFromUtf8(entry_name))) {
1839+
return false;
1840+
}
18101841
} else if (entry.is_regular_file()) {
18111842
if (!allowed_suffixes.empty()) {
18121843
std::string ext = PathToUtf8(entry.path().extension());
@@ -1823,10 +1854,16 @@ VxCoreError RawFolderManager::ImportFolder(const std::string &dest_folder_path,
18231854
fs::copy_options::overwrite_existing);
18241855
}
18251856
}
1857+
return true;
18261858
};
18271859

18281860
try {
1829-
copy_filtered(external_path, target_path);
1861+
if (!copy_filtered(external_path, target_path)) {
1862+
VXCORE_LOG_ERROR("ImportFolder: Failed to copy folder: source entry unreadable");
1863+
std::error_code rollback_ec;
1864+
fs::remove_all(target_path, rollback_ec);
1865+
return VXCORE_ERR_IO;
1866+
}
18301867
VXCORE_LOG_DEBUG("ImportFolder: Copied folder to: %s", PathToUtf8(target_path).c_str());
18311868
} catch (const std::exception &e) {
18321869
VXCORE_LOG_ERROR("ImportFolder: Failed to copy folder: %s", e.what());

0 commit comments

Comments
 (0)