From 40ecc93cb84b263d913e4db19e2d77d86257e9b1 Mon Sep 17 00:00:00 2001 From: lo-simon Date: Sat, 1 Aug 2026 00:36:49 +0100 Subject: [PATCH 1/5] Fix traversal attacks to support forward/backward-slash, multiple levels, and mixed separators --- Development/nmos/filesystem_route.cpp | 112 ++++++++++++++++++++++++-- 1 file changed, 107 insertions(+), 5 deletions(-) diff --git a/Development/nmos/filesystem_route.cpp b/Development/nmos/filesystem_route.cpp index 19612e3c..293cf686 100644 --- a/Development/nmos/filesystem_route.cpp +++ b/Development/nmos/filesystem_route.cpp @@ -32,6 +32,81 @@ namespace nmos utility::string_t::size_type slash = pathname.find_last_of(U("\\/")) + 1; return slash < dot ? pathname.substr(dot) : utility::string_t(); } + + // lexically normalizes a URI relative path to prevent directory traversal + // returns true on success, or false if the path attempts to traverse above the root (e.g., /../secret) + inline bool lexically_normalize_path(const utility::string_t& relative_path, utility::string_t& normalized_out) + { + using char_t = utility::string_t::value_type; + + std::vector segments; + utility::string_t segment; + + // split path by forward slashes + for (char_t c : relative_path) + { + if (c == U('/')) + { + if (!segment.empty()) + { + if (segment == U("..")) + { + if (segments.empty()) + { + return false; // traversing above root! + } + segments.pop_back(); + } + else if (segment != U(".")) + { + segments.push_back(segment); + } + segment.clear(); + } + } + else + { + segment += c; + } + } + + // handle trailing segment + if (!segment.empty()) + { + if (segment == U("..")) + { + if (segments.empty()) + { + return false; // traversing above root! + } + segments.pop_back(); + } + else if (segment != U(".")) + { + segments.push_back(segment); + } + } + + // reconstruct normalized path + normalized_out.clear(); + for (const auto& seg : segments) + { + normalized_out += U('/') + seg; + } + + // preserve trailing slash if original path ended with one (and isn't root) + if (!relative_path.empty() && relative_path.back() == U('/') && !normalized_out.empty()) + { + normalized_out += U('/'); + } + + if (normalized_out.empty()) + { + normalized_out = U("/"); + } + + return true; + } } // determines content type based only on file extension, and rejects unexpected file types @@ -75,13 +150,33 @@ namespace nmos { nmos::api_gate gate(gate_, req, parameters); slog::log(gate, SLOG_FLF) << "Filesystem request received"; - auto relative_path = web::uri::decode(parameters.at(U("filesystem-relative-path"))); - const bool naughty = string_t::npos != relative_path.find(U("/..")); - if (!naughty) + + auto raw_relative_path = web::uri::decode(parameters.at(U("filesystem-relative-path"))); + + // normalize path separators to forward slashes + // + // converts all backslashes(\) to forward slashes(/ ) to prevent bypass attempts using: + // - %5C..%5C (URL - encoded backslashes) + // - Mixed separators like /foo\..\bar + // Example : + // - Input : \..\..\secret.txt -> Output : /../../secret.txt + // - Input : /foo\bar\..\baz -> Output : /foo/bar/../baz + std::replace(raw_relative_path.begin(), raw_relative_path.end(), U('\\'), U('/')); + + // lexically resolve '.' and '..' segments to prevent directory traversal safely + utility::string_t relative_path; + if (!details::lexically_normalize_path(raw_relative_path, relative_path)) { - // relative path will begin with a slash - auto filesystem_path = filesystem_root + relative_path; + // Directory traversal attempt (e.g. tried to go above root) + set_reply(res, status_codes::Forbidden); + return pplx::task_from_result(true); + } + + auto filesystem_path = filesystem_root + relative_path; + try + { + // check directory index redirects if (bst::filesystem::is_directory(details::native_path(filesystem_path))) { const auto index_redirect = index_redirect_handler(relative_path); @@ -100,6 +195,7 @@ namespace nmos } } + // serve regular file if (bst::filesystem::is_regular_file(details::native_path(filesystem_path))) { const auto content_type = content_type_handler(relative_path); @@ -115,6 +211,12 @@ namespace nmos } } } + catch (...) + { + // file access error or path missing + set_reply(res, status_codes::NotFound); + return pplx::task_from_result(true); + } // unless requests are for files of a supported file type that are actually found in the filesystem, just report not found set_reply(res, status_codes::NotFound); From d97f6840982e0f0000d76581ed9563b35a0f9f12 Mon Sep 17 00:00:00 2001 From: lo-simon Date: Sat, 1 Aug 2026 00:48:19 +0100 Subject: [PATCH 2/5] Add filesystem_route_test for different path combinations --- Development/cmake/NmosCppTest.cmake | 1 + .../nmos/test/filesystem_route_test.cpp | 193 ++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 Development/nmos/test/filesystem_route_test.cpp diff --git a/Development/cmake/NmosCppTest.cmake b/Development/cmake/NmosCppTest.cmake index 4979a894..7f781413 100644 --- a/Development/cmake/NmosCppTest.cmake +++ b/Development/cmake/NmosCppTest.cmake @@ -54,6 +54,7 @@ set(NMOS_CPP_TEST_NMOS_TEST_SOURCES nmos/test/control_protocol_utils_test.cpp nmos/test/did_sdid_test.cpp nmos/test/event_type_test.cpp + nmos/test/filesystem_route_test.cpp nmos/test/json_validator_test.cpp nmos/test/jwt_generator_test.cpp nmos/test/jwt_validation_test.cpp diff --git a/Development/nmos/test/filesystem_route_test.cpp b/Development/nmos/test/filesystem_route_test.cpp new file mode 100644 index 00000000..0d76b184 --- /dev/null +++ b/Development/nmos/test/filesystem_route_test.cpp @@ -0,0 +1,193 @@ +// The first "test" is of course whether the header compiles standalone +#include "nmos/filesystem_route.h" + +#include "bst/test/test.h" + +//////////////////////////////////////////////////////////////////////////////////////////// +// Test the lexically_normalize_path function (accessing internal implementation) +namespace nmos +{ + namespace experimental + { + namespace details + { + // Expose the internal function for testing + bool lexically_normalize_path(const utility::string_t& relative_path, utility::string_t& normalized_out); + } + } +} + +//////////////////////////////////////////////////////////////////////////////////////////// +BST_TEST_CASE(testLexicallyNormalizePathBasicPaths) +{ + utility::string_t normalized; + + // Test: Simple path normalization + BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/foo/bar"), normalized)); + BST_REQUIRE_EQUAL(U("/foo/bar"), normalized); + + // Test: Root path + BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/"), normalized)); + BST_REQUIRE_EQUAL(U("/"), normalized); + + // Test: Path with trailing slash + BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/foo/bar/"), normalized)); + BST_REQUIRE_EQUAL(U("/foo/bar/"), normalized); +} + +//////////////////////////////////////////////////////////////////////////////////////////// +BST_TEST_CASE(testLexicallyNormalizePathDotSegments) +{ + utility::string_t normalized; + + // Test: Single dot (current directory) - should be removed + BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/foo/./bar"), normalized)); + BST_REQUIRE_EQUAL(U("/foo/bar"), normalized); + + // Test: Multiple single dots + BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/foo/./././bar"), normalized)); + BST_REQUIRE_EQUAL(U("/foo/bar"), normalized); + + // Test: Path with only dot segments + BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/./././"), normalized)); + BST_REQUIRE_EQUAL(U("/"), normalized); +} + +//////////////////////////////////////////////////////////////////////////////////////////// +BST_TEST_CASE(testLexicallyNormalizePathDoubleDotSegments) +{ + utility::string_t normalized; + + // Test: Normal parent directory navigation + BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/foo/bar/../baz"), normalized)); + BST_REQUIRE_EQUAL(U("/foo/baz"), normalized); + + // Test: Multiple parent directory navigations + BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/foo/bar/baz/../../qux"), normalized)); + BST_REQUIRE_EQUAL(U("/foo/qux"), normalized); + + // Test: Navigate to root + BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/foo/../"), normalized)); + BST_REQUIRE_EQUAL(U("/"), normalized); + + // Test: Complex navigation + BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/a/b/../c/./d/../e"), normalized)); + BST_REQUIRE_EQUAL(U("/a/c/e"), normalized); +} + +//////////////////////////////////////////////////////////////////////////////////////////// +BST_TEST_CASE(testLexicallyNormalizePathDirectoryTraversalAttacks) +{ + utility::string_t normalized; + + // Test: Attempt to traverse above root (single level) + BST_REQUIRE(!nmos::experimental::details::lexically_normalize_path(U("/../secret"), normalized)); + + // Test: Attempt to traverse above root (multiple levels) + BST_REQUIRE(!nmos::experimental::details::lexically_normalize_path(U("/../../etc/passwd"), normalized)); + + // Test: Attempt to traverse after valid path + BST_REQUIRE(!nmos::experimental::details::lexically_normalize_path(U("/foo/../../bar"), normalized)); + + // Test: Complex traversal attack + BST_REQUIRE(!nmos::experimental::details::lexically_normalize_path(U("/a/b/c/../../../../../../../etc/shadow"), normalized)); + + // Test: Traversal at the boundary (should fail) + BST_REQUIRE(!nmos::experimental::details::lexically_normalize_path(U("/../"), normalized)); + + // Test: Multiple consecutive parent references + BST_REQUIRE(!nmos::experimental::details::lexically_normalize_path(U("/../../../"), normalized)); +} + +//////////////////////////////////////////////////////////////////////////////////////////// +BST_TEST_CASE(testLexicallyNormalizePathEdgeCases) +{ + utility::string_t normalized; + + // Test: Empty segments (double slashes) + BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/foo//bar"), normalized)); + BST_REQUIRE_EQUAL(U("/foo/bar"), normalized); + + // Test: Multiple consecutive slashes + BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/foo///bar////baz"), normalized)); + BST_REQUIRE_EQUAL(U("/foo/bar/baz"), normalized); + + // Test: Trailing dots and slashes + BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/foo/bar/./"), normalized)); + BST_REQUIRE_EQUAL(U("/foo/bar/"), normalized); + + // Test: Path with filename + BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/foo/bar/index.html"), normalized)); + BST_REQUIRE_EQUAL(U("/foo/bar/index.html"), normalized); + + // Test: Path with file extension containing dots + BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/foo/file.min.js"), normalized)); + BST_REQUIRE_EQUAL(U("/foo/file.min.js"), normalized); +} + +//////////////////////////////////////////////////////////////////////////////////////////// +BST_TEST_CASE(testLexicallyNormalizePathUnicodeAndSpecialCharacters) +{ + utility::string_t normalized; + + // Test: Paths with spaces (after URL decode) + BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/my folder/my file.txt"), normalized)); + BST_REQUIRE_EQUAL(U("/my folder/my file.txt"), normalized); + + // Test: Paths with hyphens and underscores + BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/my-folder/my_file.txt"), normalized)); + BST_REQUIRE_EQUAL(U("/my-folder/my_file.txt"), normalized); + + // Test: Paths with numbers + BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/folder123/file456.txt"), normalized)); + BST_REQUIRE_EQUAL(U("/folder123/file456.txt"), normalized); + + // Test: Paths with mixed case + BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/MyFolder/MyFile.TXT"), normalized)); + BST_REQUIRE_EQUAL(U("/MyFolder/MyFile.TXT"), normalized); +} + +//////////////////////////////////////////////////////////////////////////////////////////// +BST_TEST_CASE(testURLEncodedBackslashAttackVector) +{ + // Simulate the complete attack vector: %5C..%5C..%5Csecret.txt + + // Step 1: After URL decode, %5C becomes `\` + utility::string_t decoded_path = U("\\..\\..\\secret.json"); + + // Step 2: Normalize backslashes to forward slashes (as done in make_filesystem_route) + std::replace(decoded_path.begin(), decoded_path.end(), U('\\'), U('/')); + BST_REQUIRE_EQUAL(U("/../../secret.json"), decoded_path); + + // Step 3: Lexical normalization should REJECT this + utility::string_t normalized; + BST_REQUIRE(!nmos::experimental::details::lexically_normalize_path(decoded_path, normalized)); +} + +//////////////////////////////////////////////////////////////////////////////////////////// +BST_TEST_CASE(testComplexMixedAttackVectors) +{ + utility::string_t normalized; + + // Test: Mixed forward and backward slashes (after conversion to forward) + { + utility::string_t mixed = U("/foo\\bar/../baz"); + std::replace(mixed.begin(), mixed.end(), U('\\'), U('/')); + BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(mixed, normalized)); + BST_REQUIRE_EQUAL(U("/foo/baz"), normalized); + } + + // Test: Complex attack with multiple techniques + { + utility::string_t attack = U("/foo\\..\\..\\..\\secret"); + std::replace(attack.begin(), attack.end(), U('\\'), U('/')); + BST_REQUIRE(!nmos::experimental::details::lexically_normalize_path(attack, normalized)); + } + + // Test: URL-encoded double dot with backslash + { + utility::string_t attack = U("\\..\\admin\\config"); + std::replace(attack.begin(), attack.end(), U('\\'), U('/')); + BST_REQUIRE(!nmos::experimental::details::lexically_normalize_path(attack, normalized)); + } +} From 69e4f55e54bba35ebd33b56ba5cb220d147bb046 Mon Sep 17 00:00:00 2001 From: lo-simon Date: Mon, 3 Aug 2026 23:39:07 +0100 Subject: [PATCH 3/5] Revert the changes and simply include the directory traversal attempts using both forward and backward slashes --- Development/cmake/NmosCppTest.cmake | 1 - Development/nmos/filesystem_route.cpp | 121 +---------- .../nmos/test/filesystem_route_test.cpp | 193 ------------------ 3 files changed, 10 insertions(+), 305 deletions(-) delete mode 100644 Development/nmos/test/filesystem_route_test.cpp diff --git a/Development/cmake/NmosCppTest.cmake b/Development/cmake/NmosCppTest.cmake index 7f781413..4979a894 100644 --- a/Development/cmake/NmosCppTest.cmake +++ b/Development/cmake/NmosCppTest.cmake @@ -54,7 +54,6 @@ set(NMOS_CPP_TEST_NMOS_TEST_SOURCES nmos/test/control_protocol_utils_test.cpp nmos/test/did_sdid_test.cpp nmos/test/event_type_test.cpp - nmos/test/filesystem_route_test.cpp nmos/test/json_validator_test.cpp nmos/test/jwt_generator_test.cpp nmos/test/jwt_validation_test.cpp diff --git a/Development/nmos/filesystem_route.cpp b/Development/nmos/filesystem_route.cpp index 293cf686..97b2a2d6 100644 --- a/Development/nmos/filesystem_route.cpp +++ b/Development/nmos/filesystem_route.cpp @@ -32,81 +32,6 @@ namespace nmos utility::string_t::size_type slash = pathname.find_last_of(U("\\/")) + 1; return slash < dot ? pathname.substr(dot) : utility::string_t(); } - - // lexically normalizes a URI relative path to prevent directory traversal - // returns true on success, or false if the path attempts to traverse above the root (e.g., /../secret) - inline bool lexically_normalize_path(const utility::string_t& relative_path, utility::string_t& normalized_out) - { - using char_t = utility::string_t::value_type; - - std::vector segments; - utility::string_t segment; - - // split path by forward slashes - for (char_t c : relative_path) - { - if (c == U('/')) - { - if (!segment.empty()) - { - if (segment == U("..")) - { - if (segments.empty()) - { - return false; // traversing above root! - } - segments.pop_back(); - } - else if (segment != U(".")) - { - segments.push_back(segment); - } - segment.clear(); - } - } - else - { - segment += c; - } - } - - // handle trailing segment - if (!segment.empty()) - { - if (segment == U("..")) - { - if (segments.empty()) - { - return false; // traversing above root! - } - segments.pop_back(); - } - else if (segment != U(".")) - { - segments.push_back(segment); - } - } - - // reconstruct normalized path - normalized_out.clear(); - for (const auto& seg : segments) - { - normalized_out += U('/') + seg; - } - - // preserve trailing slash if original path ended with one (and isn't root) - if (!relative_path.empty() && relative_path.back() == U('/') && !normalized_out.empty()) - { - normalized_out += U('/'); - } - - if (normalized_out.empty()) - { - normalized_out = U("/"); - } - - return true; - } } // determines content type based only on file extension, and rejects unexpected file types @@ -150,33 +75,14 @@ namespace nmos { nmos::api_gate gate(gate_, req, parameters); slog::log(gate, SLOG_FLF) << "Filesystem request received"; - - auto raw_relative_path = web::uri::decode(parameters.at(U("filesystem-relative-path"))); - - // normalize path separators to forward slashes - // - // converts all backslashes(\) to forward slashes(/ ) to prevent bypass attempts using: - // - %5C..%5C (URL - encoded backslashes) - // - Mixed separators like /foo\..\bar - // Example : - // - Input : \..\..\secret.txt -> Output : /../../secret.txt - // - Input : /foo\bar\..\baz -> Output : /foo/bar/../baz - std::replace(raw_relative_path.begin(), raw_relative_path.end(), U('\\'), U('/')); - - // lexically resolve '.' and '..' segments to prevent directory traversal safely - utility::string_t relative_path; - if (!details::lexically_normalize_path(raw_relative_path, relative_path)) + auto relative_path = web::uri::decode(parameters.at(U("filesystem-relative-path"))); + // Check for directory traversal attempts using both forward and backward slashes + const bool naughty = (string_t::npos != relative_path.find(U("/.."))) || (string_t::npos != relative_path.find(U("\\.."))); + if (!naughty) { - // Directory traversal attempt (e.g. tried to go above root) - set_reply(res, status_codes::Forbidden); - return pplx::task_from_result(true); - } - - auto filesystem_path = filesystem_root + relative_path; + // relative path will begin with a slash + auto filesystem_path = filesystem_root + relative_path; - try - { - // check directory index redirects if (bst::filesystem::is_directory(details::native_path(filesystem_path))) { const auto index_redirect = index_redirect_handler(relative_path); @@ -195,7 +101,6 @@ namespace nmos } } - // serve regular file if (bst::filesystem::is_regular_file(details::native_path(filesystem_path))) { const auto content_type = content_type_handler(relative_path); @@ -204,19 +109,13 @@ namespace nmos { const utility::size64_t content_length = bst::filesystem::file_size(details::native_path(filesystem_path)); return concurrency::streams::fstream::open_istream(filesystem_path, std::ios::in).then([res, content_length, content_type](concurrency::streams::istream is) mutable - { - set_reply(res, status_codes::OK, is, content_length, content_type); - return true; - }); + { + set_reply(res, status_codes::OK, is, content_length, content_type); + return true; + }); } } } - catch (...) - { - // file access error or path missing - set_reply(res, status_codes::NotFound); - return pplx::task_from_result(true); - } // unless requests are for files of a supported file type that are actually found in the filesystem, just report not found set_reply(res, status_codes::NotFound); diff --git a/Development/nmos/test/filesystem_route_test.cpp b/Development/nmos/test/filesystem_route_test.cpp deleted file mode 100644 index 0d76b184..00000000 --- a/Development/nmos/test/filesystem_route_test.cpp +++ /dev/null @@ -1,193 +0,0 @@ -// The first "test" is of course whether the header compiles standalone -#include "nmos/filesystem_route.h" - -#include "bst/test/test.h" - -//////////////////////////////////////////////////////////////////////////////////////////// -// Test the lexically_normalize_path function (accessing internal implementation) -namespace nmos -{ - namespace experimental - { - namespace details - { - // Expose the internal function for testing - bool lexically_normalize_path(const utility::string_t& relative_path, utility::string_t& normalized_out); - } - } -} - -//////////////////////////////////////////////////////////////////////////////////////////// -BST_TEST_CASE(testLexicallyNormalizePathBasicPaths) -{ - utility::string_t normalized; - - // Test: Simple path normalization - BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/foo/bar"), normalized)); - BST_REQUIRE_EQUAL(U("/foo/bar"), normalized); - - // Test: Root path - BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/"), normalized)); - BST_REQUIRE_EQUAL(U("/"), normalized); - - // Test: Path with trailing slash - BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/foo/bar/"), normalized)); - BST_REQUIRE_EQUAL(U("/foo/bar/"), normalized); -} - -//////////////////////////////////////////////////////////////////////////////////////////// -BST_TEST_CASE(testLexicallyNormalizePathDotSegments) -{ - utility::string_t normalized; - - // Test: Single dot (current directory) - should be removed - BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/foo/./bar"), normalized)); - BST_REQUIRE_EQUAL(U("/foo/bar"), normalized); - - // Test: Multiple single dots - BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/foo/./././bar"), normalized)); - BST_REQUIRE_EQUAL(U("/foo/bar"), normalized); - - // Test: Path with only dot segments - BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/./././"), normalized)); - BST_REQUIRE_EQUAL(U("/"), normalized); -} - -//////////////////////////////////////////////////////////////////////////////////////////// -BST_TEST_CASE(testLexicallyNormalizePathDoubleDotSegments) -{ - utility::string_t normalized; - - // Test: Normal parent directory navigation - BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/foo/bar/../baz"), normalized)); - BST_REQUIRE_EQUAL(U("/foo/baz"), normalized); - - // Test: Multiple parent directory navigations - BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/foo/bar/baz/../../qux"), normalized)); - BST_REQUIRE_EQUAL(U("/foo/qux"), normalized); - - // Test: Navigate to root - BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/foo/../"), normalized)); - BST_REQUIRE_EQUAL(U("/"), normalized); - - // Test: Complex navigation - BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/a/b/../c/./d/../e"), normalized)); - BST_REQUIRE_EQUAL(U("/a/c/e"), normalized); -} - -//////////////////////////////////////////////////////////////////////////////////////////// -BST_TEST_CASE(testLexicallyNormalizePathDirectoryTraversalAttacks) -{ - utility::string_t normalized; - - // Test: Attempt to traverse above root (single level) - BST_REQUIRE(!nmos::experimental::details::lexically_normalize_path(U("/../secret"), normalized)); - - // Test: Attempt to traverse above root (multiple levels) - BST_REQUIRE(!nmos::experimental::details::lexically_normalize_path(U("/../../etc/passwd"), normalized)); - - // Test: Attempt to traverse after valid path - BST_REQUIRE(!nmos::experimental::details::lexically_normalize_path(U("/foo/../../bar"), normalized)); - - // Test: Complex traversal attack - BST_REQUIRE(!nmos::experimental::details::lexically_normalize_path(U("/a/b/c/../../../../../../../etc/shadow"), normalized)); - - // Test: Traversal at the boundary (should fail) - BST_REQUIRE(!nmos::experimental::details::lexically_normalize_path(U("/../"), normalized)); - - // Test: Multiple consecutive parent references - BST_REQUIRE(!nmos::experimental::details::lexically_normalize_path(U("/../../../"), normalized)); -} - -//////////////////////////////////////////////////////////////////////////////////////////// -BST_TEST_CASE(testLexicallyNormalizePathEdgeCases) -{ - utility::string_t normalized; - - // Test: Empty segments (double slashes) - BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/foo//bar"), normalized)); - BST_REQUIRE_EQUAL(U("/foo/bar"), normalized); - - // Test: Multiple consecutive slashes - BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/foo///bar////baz"), normalized)); - BST_REQUIRE_EQUAL(U("/foo/bar/baz"), normalized); - - // Test: Trailing dots and slashes - BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/foo/bar/./"), normalized)); - BST_REQUIRE_EQUAL(U("/foo/bar/"), normalized); - - // Test: Path with filename - BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/foo/bar/index.html"), normalized)); - BST_REQUIRE_EQUAL(U("/foo/bar/index.html"), normalized); - - // Test: Path with file extension containing dots - BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/foo/file.min.js"), normalized)); - BST_REQUIRE_EQUAL(U("/foo/file.min.js"), normalized); -} - -//////////////////////////////////////////////////////////////////////////////////////////// -BST_TEST_CASE(testLexicallyNormalizePathUnicodeAndSpecialCharacters) -{ - utility::string_t normalized; - - // Test: Paths with spaces (after URL decode) - BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/my folder/my file.txt"), normalized)); - BST_REQUIRE_EQUAL(U("/my folder/my file.txt"), normalized); - - // Test: Paths with hyphens and underscores - BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/my-folder/my_file.txt"), normalized)); - BST_REQUIRE_EQUAL(U("/my-folder/my_file.txt"), normalized); - - // Test: Paths with numbers - BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/folder123/file456.txt"), normalized)); - BST_REQUIRE_EQUAL(U("/folder123/file456.txt"), normalized); - - // Test: Paths with mixed case - BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(U("/MyFolder/MyFile.TXT"), normalized)); - BST_REQUIRE_EQUAL(U("/MyFolder/MyFile.TXT"), normalized); -} - -//////////////////////////////////////////////////////////////////////////////////////////// -BST_TEST_CASE(testURLEncodedBackslashAttackVector) -{ - // Simulate the complete attack vector: %5C..%5C..%5Csecret.txt - - // Step 1: After URL decode, %5C becomes `\` - utility::string_t decoded_path = U("\\..\\..\\secret.json"); - - // Step 2: Normalize backslashes to forward slashes (as done in make_filesystem_route) - std::replace(decoded_path.begin(), decoded_path.end(), U('\\'), U('/')); - BST_REQUIRE_EQUAL(U("/../../secret.json"), decoded_path); - - // Step 3: Lexical normalization should REJECT this - utility::string_t normalized; - BST_REQUIRE(!nmos::experimental::details::lexically_normalize_path(decoded_path, normalized)); -} - -//////////////////////////////////////////////////////////////////////////////////////////// -BST_TEST_CASE(testComplexMixedAttackVectors) -{ - utility::string_t normalized; - - // Test: Mixed forward and backward slashes (after conversion to forward) - { - utility::string_t mixed = U("/foo\\bar/../baz"); - std::replace(mixed.begin(), mixed.end(), U('\\'), U('/')); - BST_REQUIRE(nmos::experimental::details::lexically_normalize_path(mixed, normalized)); - BST_REQUIRE_EQUAL(U("/foo/baz"), normalized); - } - - // Test: Complex attack with multiple techniques - { - utility::string_t attack = U("/foo\\..\\..\\..\\secret"); - std::replace(attack.begin(), attack.end(), U('\\'), U('/')); - BST_REQUIRE(!nmos::experimental::details::lexically_normalize_path(attack, normalized)); - } - - // Test: URL-encoded double dot with backslash - { - utility::string_t attack = U("\\..\\admin\\config"); - std::replace(attack.begin(), attack.end(), U('\\'), U('/')); - BST_REQUIRE(!nmos::experimental::details::lexically_normalize_path(attack, normalized)); - } -} From b969f45bd094b691920b41662219100131bba811 Mon Sep 17 00:00:00 2001 From: lo-simon Date: Mon, 3 Aug 2026 23:42:33 +0100 Subject: [PATCH 4/5] Remove ident --- Development/nmos/filesystem_route.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Development/nmos/filesystem_route.cpp b/Development/nmos/filesystem_route.cpp index 97b2a2d6..39749acf 100644 --- a/Development/nmos/filesystem_route.cpp +++ b/Development/nmos/filesystem_route.cpp @@ -109,10 +109,10 @@ namespace nmos { const utility::size64_t content_length = bst::filesystem::file_size(details::native_path(filesystem_path)); return concurrency::streams::fstream::open_istream(filesystem_path, std::ios::in).then([res, content_length, content_type](concurrency::streams::istream is) mutable - { - set_reply(res, status_codes::OK, is, content_length, content_type); - return true; - }); + { + set_reply(res, status_codes::OK, is, content_length, content_type); + return true; + }); } } } From 928fc29d4a3d9e77d174269c531346ead639459b Mon Sep 17 00:00:00 2001 From: lo-simon Date: Tue, 4 Aug 2026 11:56:06 +0100 Subject: [PATCH 5/5] ban URL paths with `\` --- Development/nmos/filesystem_route.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Development/nmos/filesystem_route.cpp b/Development/nmos/filesystem_route.cpp index 39749acf..c7924d5c 100644 --- a/Development/nmos/filesystem_route.cpp +++ b/Development/nmos/filesystem_route.cpp @@ -77,7 +77,7 @@ namespace nmos slog::log(gate, SLOG_FLF) << "Filesystem request received"; auto relative_path = web::uri::decode(parameters.at(U("filesystem-relative-path"))); // Check for directory traversal attempts using both forward and backward slashes - const bool naughty = (string_t::npos != relative_path.find(U("/.."))) || (string_t::npos != relative_path.find(U("\\.."))); + const bool naughty = (string_t::npos != relative_path.find(U("/.."))) || (string_t::npos != relative_path.find(U("\\"))); if (!naughty) { // relative path will begin with a slash