From b4b24589ada641e717fb5126da8635eaa93d6484 Mon Sep 17 00:00:00 2001 From: yhirose Date: Thu, 22 Oct 2026 11:16:27 -0400 Subject: [PATCH] Refuse to serve a path segment ending in a dot or a space Windows trims a trailing '.' or space from a file name, so a mount point there served a file under a path other than the one a pre-routing handler checked. A mount point now serves no path with a segment ending in '.' or a space, on every platform; "." and ".." are among them. The README no longer lists such names among the ones the file system can still alias. --- README.md | 2 +- httplib.h | 11 ++++++----- test/test.cc | 15 ++++++++++++--- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index fcfcf379..a03fa300 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 `/`, 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 a name with a trailing `.` or space, or 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 `/`, 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. ```cpp // User defined file extension and MIME type mappings diff --git a/httplib.h b/httplib.h index 32e551bb..055bed0f 100644 --- a/httplib.h +++ b/httplib.h @@ -5988,9 +5988,10 @@ inline bool is_valid_close_payload(const std::string &payload) { namespace detail { -// A path a mount point serves: absolute, with no ".", ".." or empty segment -// except a trailing '/', so the file served is the one that req.path names to -// the handlers that ran before it. +// 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. inline bool is_valid_path(const std::string &path) { if (path.empty() || path[0] != '/') { return false; } @@ -6001,8 +6002,8 @@ inline bool is_valid_path(const std::string &path) { continue; } auto len = i - beg; - if ((len == 0 && i < path.size()) || (len == 1 && path[beg] == '.') || - (len == 2 && path[beg] == '.' && path[beg + 1] == '.')) { + if ((len == 0 && i < path.size()) || + (len > 0 && (path[i - 1] == '.' || path[i - 1] == ' '))) { return false; } beg = i + 1; diff --git a/test/test.cc b/test/test.cc index 78888d1e..0ceb6829 100644 --- a/test/test.cc +++ b/test/test.cc @@ -566,6 +566,7 @@ find_content_type(const std::string &path, const std::string &default_content_type); bool can_compress_content_type(const std::string &content_type); bool is_prohibited_header_name(const std::string &name); +bool is_valid_path(const std::string &path); #ifdef CPPHTTPLIB_SSL_ENABLED bool match_hostname(const std::string &pattern, const std::string &hostname); #endif @@ -693,6 +694,12 @@ TEST(FindContentTypeTest, ComparesWholeExtensions) { EXPECT_EQ("", detail::find_content_type("/a.l5", {}, "")); } +TEST(IsValidPathTest, RejectsSegmentsEndingInDotOrSpace) { + for (auto path : {"/a.", "/a ", "/dir./a", "/dir /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")); @@ -12607,8 +12614,9 @@ TEST(MountTest, MultibytesPathName) { EXPECT_EQ(U8("日本語コンテンツ"), res->body); } -// A mount point serves no path with an empty, "." or ".." segment, so a guard -// in the pre-routing handler sees the path of every file that is served +// A mount point serves no path with an empty segment or one ending in '.' or +// a space, so a guard in the pre-routing handler sees the path of every file +// that is served TEST(MountTest, AliasedPathDoesNotPassPreRoutingGuard) { Server svr; ASSERT_TRUE(svr.set_mount_point("/", "./www")); @@ -12633,7 +12641,8 @@ TEST(MountTest, AliasedPathDoesNotPassPreRoutingGuard) { Client cli(HOST, port); 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/../dir/test.html", "/%2e/dir/test.html", + "/dir/test.html.", "/dir/test.html%20"}) { SCOPED_TRACE(path); auto res = cli.Get(path); ASSERT_TRUE(res) << "Error: " << to_string(res.error());