From fd3205e5dc95e1d9c949c0ca33a79be4571eb3b9 Mon Sep 17 00:00:00 2001 From: Sayed Kaif Date: Thu, 22 Oct 2026 21:13:42 +0530 Subject: [PATCH 1/2] refuse a ':' in a path a mount point serves On NTFS a ':' in a file name selects a stream of the file, so "/a.txt::$DATA" serves a.txt past a pre-routing check on "/a.txt", and "/a.txt:name" serves a named stream such as Zone.Identifier. is_valid_path now refuses ':' on every platform, as it does a segment ending in '.' or a space. --- README.md | 2 +- httplib.h | 11 +++++++---- test/test.cc | 9 ++++++++- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a03fa3000d..dc19d24213 100644 --- a/README.md +++ b/README.md @@ -406,7 +406,7 @@ ret = svr.remove_mount_point("/"); ret = svr.remove_mount_point("/public"); ``` -A mount point serves a path only when it has no `.`, `..` or empty segment other than a trailing `/`, and no segment ending in `.` or a space, so a check on `req.path` in `set_pre_routing_handler()` sees the path of the file that is served. A path ending in `/`, and the mount point itself such as `/public`, serve the `index.html` in that directory. The file system can still give a file other names: another case on a case-insensitive file system, such as the default ones on macOS and Windows, and on Windows an 8.3 short name such as `PRIVAT~1`. Compare paths without regard to case on such a file system, and on Windows keep files that need a guard out of mounted directories. +A mount point serves a path only when it has no `.`, `..` or empty segment other than a trailing `/`, no segment ending in `.` or a space and no `:`, so a check on `req.path` in `set_pre_routing_handler()` sees the path of the file that is served. A path ending in `/`, and the mount point itself such as `/public`, serve the `index.html` in that directory. The file system can still give a file other names: another case on a case-insensitive file system, such as the default ones on macOS and Windows, and on Windows an 8.3 short name such as `PRIVAT~1`. Compare paths without regard to case on such a file system, and on Windows keep files that need a guard out of mounted directories. ```cpp // User defined file extension and MIME type mappings diff --git a/httplib.h b/httplib.h index f529734cfd..7c22864b05 100644 --- a/httplib.h +++ b/httplib.h @@ -5989,16 +5989,19 @@ inline bool is_valid_close_payload(const std::string &payload) { namespace detail { // A path a mount point serves: absolute, with no empty segment except a -// trailing '/' and no segment ending in '.' or a space (which covers "." and -// ".." and names Windows trims into another file's name), so the file served -// is the one that req.path names to the handlers that ran before it. +// trailing '/', no segment ending in '.' or a space (which covers "." and +// ".." and names Windows trims into another file's name) and no ':' (which +// names an NTFS stream, so "a.txt::$DATA" is a.txt), so the file served is the +// one that req.path names to the handlers that ran before it. inline bool is_valid_path(const std::string &path) { if (path.empty() || path[0] != '/') { return false; } auto beg = size_t{1}; for (auto i = beg; i <= path.size(); i++) { if (i < path.size() && path[i] != '/') { - if (path[i] == '\0' || path[i] == '\\') { return false; } + if (path[i] == '\0' || path[i] == '\\' || path[i] == ':') { + return false; + } continue; } auto len = i - beg; diff --git a/test/test.cc b/test/test.cc index e53aa80923..5c2fb781ad 100644 --- a/test/test.cc +++ b/test/test.cc @@ -700,6 +700,12 @@ TEST(IsValidPathTest, RejectsSegmentsEndingInDotOrSpace) { } } +TEST(IsValidPathTest, RejectsStreamNames) { + for (auto path : {"/a.txt::$DATA", "/a.txt:Zone.Identifier", "/dir:x/a"}) { + EXPECT_FALSE(detail::is_valid_path(path)) << path; + } +} + TEST(CanCompressContentTypeTest, ComparesWholeTypes) { EXPECT_TRUE(detail::can_compress_content_type("application/json")); EXPECT_TRUE(detail::can_compress_content_type("text/plain; charset=utf-8")); @@ -12642,7 +12648,8 @@ TEST(MountTest, AliasedPathDoesNotPassPreRoutingGuard) { for (auto path : {"/./dir/test.html", "/dir/./test.html", "//dir/test.html", "/dir//test.html", "/dir/../dir/test.html", "/%2e/dir/test.html", - "/dir/test.html.", "/dir/test.html%20"}) { + "/dir/test.html.", "/dir/test.html%20", "/dir/test.html::$DATA", + "/dir/test.html%3A%3A$DATA"}) { SCOPED_TRACE(path); auto res = cli.Get(path); ASSERT_TRUE(res) << "Error: " << to_string(res.error()); From 005a77c2b6970f7bd72a912cf40d6252c84b922c Mon Sep 17 00:00:00 2001 From: yhirose Date: Sun, 25 Oct 2026 22:27:07 -0400 Subject: [PATCH 2/2] Refuse ':' in a mounted path on Windows only An NTFS stream name is the only thing ':' means in a path, and only on Windows. Elsewhere it is an ordinary file name character, so a file such as 2024-01-01T10:00:00.log would stop being served. The test pins both sides. --- README.md | 2 +- httplib.h | 13 +++++++------ test/test.cc | 8 +++++++- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 5c5dbea83b..0853b797c7 100644 --- a/README.md +++ b/README.md @@ -409,7 +409,7 @@ ret = svr.remove_mount_point("/"); ret = svr.remove_mount_point("/public"); ``` -A mount point serves a path only when it has no `.`, `..` or empty segment other than a trailing `/`, no segment ending in `.` or a space and no `:`, so a check on `req.path` in `set_pre_routing_handler()` sees the path of the file that is served. A path ending in `/`, and the mount point itself such as `/public`, serve the `index.html` in that directory. The file system can still give a file other names: another case on a case-insensitive file system, such as the default ones on macOS and Windows, and on Windows an 8.3 short name such as `PRIVAT~1`. Compare paths without regard to case on such a file system, and on Windows keep files that need a guard out of mounted directories. +A mount point serves a path only when it has no `.`, `..` or empty segment other than a trailing `/`, no segment ending in `.` or a space and, on Windows, no `:` (it names an NTFS stream), so a check on `req.path` in `set_pre_routing_handler()` sees the path of the file that is served. A path ending in `/`, and the mount point itself such as `/public`, serve the `index.html` in that directory. The file system can still give a file other names: another case on a case-insensitive file system, such as the default ones on macOS and Windows, and on Windows an 8.3 short name such as `PRIVAT~1`. Compare paths without regard to case on such a file system, and on Windows keep files that need a guard out of mounted directories. ```cpp // User defined file extension and MIME type mappings diff --git a/httplib.h b/httplib.h index fe4cf53aa1..4c9cf53d7f 100644 --- a/httplib.h +++ b/httplib.h @@ -6008,18 +6008,19 @@ namespace detail { // A path a mount point serves: absolute, with no empty segment except a // trailing '/', no segment ending in '.' or a space (which covers "." and -// ".." and names Windows trims into another file's name) and no ':' (which -// names an NTFS stream, so "a.txt::$DATA" is a.txt), so the file served is the -// one that req.path names to the handlers that ran before it. +// ".." and names Windows trims into another file's name) and, on Windows, no +// ':' (which names an NTFS stream, so "a.txt::$DATA" is a.txt), so the file +// served is the one that req.path names to the handlers that ran before it. inline bool is_valid_path(const std::string &path) { if (path.empty() || path[0] != '/') { return false; } auto beg = size_t{1}; for (auto i = beg; i <= path.size(); i++) { if (i < path.size() && path[i] != '/') { - if (path[i] == '\0' || path[i] == '\\' || path[i] == ':') { - return false; - } + if (path[i] == '\0' || path[i] == '\\') { return false; } +#ifdef _WIN32 + if (path[i] == ':') { return false; } +#endif continue; } auto len = i - beg; diff --git a/test/test.cc b/test/test.cc index c57d57c2aa..0f305c047d 100644 --- a/test/test.cc +++ b/test/test.cc @@ -707,9 +707,15 @@ TEST(IsValidPathTest, RejectsSegmentsEndingInDotOrSpace) { } } -TEST(IsValidPathTest, RejectsStreamNames) { +// ':' names an NTFS stream on Windows and is an ordinary character in a file +// name anywhere else. +TEST(IsValidPathTest, ColonIsRejectedOnlyOnWindows) { for (auto path : {"/a.txt::$DATA", "/a.txt:Zone.Identifier", "/dir:x/a"}) { +#ifdef _WIN32 EXPECT_FALSE(detail::is_valid_path(path)) << path; +#else + EXPECT_TRUE(detail::is_valid_path(path)) << path; +#endif } }