Serve only canonical paths from mount points

A mount point left ".", ".." and empty path segments for the file
system to resolve, while the pre-routing handler, where the README has
applications guard static files, saw the path as it was sent. A file
could then be reached under a spelling that a check on req.path did not
match. Serve a path from a mount point only when it has none of those
segments other than a trailing '/', so that the file served is the one
req.path names; any other path now gets 404. The README also notes the
other names a file system can give a file.
This commit is contained in:
yhirose committed 2026-10-21 16:55:43 -04:00
1 parent 98647e059d
commit 8772d10c90
3 files changed
+57 -48

No files matched your search

+2
View File
@@ -406,6 +406,8 @@ ret = svr.remove_mount_point("/");
ret = svr.remove_mount_point("/public"); 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.
```cpp ```cpp
// User defined file extension and MIME type mappings // User defined file extension and MIME type mappings
svr.set_file_extension_and_mimetype_mapping("cc", "text/x-c"); svr.set_file_extension_and_mimetype_mapping("cc", "text/x-c");
+17 -40
View File
@@ -5955,45 +5955,25 @@ inline bool is_valid_close_payload(const std::string &payload) {
namespace detail { 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.
inline bool is_valid_path(const std::string &path) { inline bool is_valid_path(const std::string &path) {
size_t level = 0; if (path.empty() || path[0] != '/') { return false; }
size_t i = 0;
// Skip slash auto beg = size_t{1};
while (i < path.size() && path[i] == '/') { for (auto i = beg; i <= path.size(); i++) {
i++; if (i < path.size() && path[i] != '/') {
if (path[i] == '\0' || path[i] == '\\') { return false; }
continue;
} }
while (i < path.size()) {
// Read component
auto beg = i;
while (i < path.size() && path[i] != '/') {
if (path[i] == '\0') {
return false;
} else if (path[i] == '\\') {
return false;
}
i++;
}
auto len = i - beg; auto len = i - beg;
assert(len > 0); if ((len == 0 && i < path.size()) || (len == 1 && path[beg] == '.') ||
(len == 2 && path[beg] == '.' && path[beg + 1] == '.')) {
if (!path.compare(beg, len, ".")) { return false;
;
} else if (!path.compare(beg, len, "..")) {
if (level == 0) { return false; }
level--;
} else {
level++;
} }
beg = i + 1;
// Skip slash
while (i < path.size() && path[i] == '/') {
i++;
} }
}
return true; return true;
} }
@@ -14330,6 +14310,8 @@ inline bool Server::read_content_core(
} }
inline bool Server::handle_file_request(Request &req, Response &res) { inline bool Server::handle_file_request(Request &req, Response &res) {
if (!detail::is_valid_path(req.path)) { return false; }
for (const auto &entry : base_dirs_) { for (const auto &entry : base_dirs_) {
// Prefix match, on a path segment boundary. A mount point of "/mount" // Prefix match, on a path segment boundary. A mount point of "/mount"
// covers "/mount" and "/mount/...", but must not swallow "/mountdir/...". // covers "/mount" and "/mount/...", but must not swallow "/mountdir/...".
@@ -14340,7 +14322,6 @@ inline bool Server::handle_file_request(Request &req, Response &res) {
req.path.size() == entry.mount_point.size() || req.path.size() == entry.mount_point.size() ||
req.path[entry.mount_point.size()] == '/')) { req.path[entry.mount_point.size()] == '/')) {
std::string sub_path = "/" + req.path.substr(entry.mount_point.size()); std::string sub_path = "/" + req.path.substr(entry.mount_point.size());
if (detail::is_valid_path(sub_path)) {
auto path = entry.base_dir + sub_path; auto path = entry.base_dir + sub_path;
if (path.back() == '/') { path += "index.html"; } if (path.back() == '/') { path += "index.html"; }
@@ -14349,9 +14330,7 @@ inline bool Server::handle_file_request(Request &req, Response &res) {
// that cannot be resolved, which includes one that does not exist, is // that cannot be resolved, which includes one that does not exist, is
// not served from here. // not served from here.
std::string resolved_path; std::string resolved_path;
if (!detail::canonicalize_path(path.c_str(), resolved_path)) { if (!detail::canonicalize_path(path.c_str(), resolved_path)) { continue; }
continue;
}
if (!detail::is_path_within_base(resolved_path, if (!detail::is_path_within_base(resolved_path,
entry.resolved_base_dir)) { entry.resolved_base_dir)) {
res.status = StatusCode::Forbidden_403; res.status = StatusCode::Forbidden_403;
@@ -14378,8 +14357,7 @@ inline bool Server::handle_file_request(Request &req, Response &res) {
path, file_extension_and_mimetype_map_, default_file_mimetype_); path, file_extension_and_mimetype_map_, default_file_mimetype_);
auto encoding = detail::EncodingType::None; auto encoding = detail::EncodingType::None;
if (static_file_compression_) { if (static_file_compression_) {
encoding = encoding = static_file_encoding(req, res, content_type, stat.size());
static_file_encoding(req, res, content_type, stat.size());
} }
// The ETag names the representation actually sent, so a client that // The ETag names the representation actually sent, so a client that
@@ -14421,7 +14399,6 @@ inline bool Server::handle_file_request(Request &req, Response &res) {
} }
} }
} }
}
return false; return false;
} }
+38 -8
View File
@@ -7417,9 +7417,7 @@ TEST_F(ServerTest, GetMethodDirTest) {
TEST_F(ServerTest, GetMethodDirTestWithDoubleDots) { TEST_F(ServerTest, GetMethodDirTestWithDoubleDots) {
auto res = cli_.Get("/dir/../dir/test.html"); auto res = cli_.Get("/dir/../dir/test.html");
ASSERT_TRUE(res) << "Error: " << to_string(res.error()); ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status); EXPECT_EQ(StatusCode::NotFound_404, res->status);
EXPECT_EQ("text/html", res->get_header_value("Content-Type"));
EXPECT_EQ("test.html", res->body);
} }
TEST_F(ServerTest, GetMethodInvalidPath) { TEST_F(ServerTest, GetMethodInvalidPath) {
@@ -7451,9 +7449,7 @@ TEST_F(ServerTest, GetMethodDirMountTest) {
TEST_F(ServerTest, GetMethodDirMountTestWithDoubleDots) { TEST_F(ServerTest, GetMethodDirMountTestWithDoubleDots) {
auto res = cli_.Get("/mount/dir/../dir/test.html"); auto res = cli_.Get("/mount/dir/../dir/test.html");
ASSERT_TRUE(res) << "Error: " << to_string(res.error()); ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::OK_200, res->status); EXPECT_EQ(StatusCode::NotFound_404, res->status);
EXPECT_EQ("text/html", res->get_header_value("Content-Type"));
EXPECT_EQ("test.html", res->body);
} }
TEST_F(ServerTest, GetMethodInvalidMountPath) { TEST_F(ServerTest, GetMethodInvalidMountPath) {
@@ -7496,8 +7492,8 @@ TEST_F(ServerTest, GetMethodDirMountWithoutTrailingSlash) {
TEST_F(ServerTest, GetMethodDirWithLeadingDoubleSlash) { TEST_F(ServerTest, GetMethodDirWithLeadingDoubleSlash) {
auto res = cli_.Get("//dir"); auto res = cli_.Get("//dir");
ASSERT_TRUE(res) << "Error: " << to_string(res.error()); ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::MovedPermanently_301, res->status); EXPECT_EQ(StatusCode::NotFound_404, res->status);
EXPECT_EQ("./dir/", res->get_header_value("Location")); EXPECT_FALSE(res->has_header("Location"));
} }
TEST_F(ServerTest, PostMethod303) { TEST_F(ServerTest, PostMethod303) {
@@ -12486,6 +12482,40 @@ TEST(MountTest, MultibytesPathName) {
EXPECT_EQ(U8("日本語コンテンツ"), res->body); 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
TEST(MountTest, AliasedPathDoesNotPassPreRoutingGuard) {
Server svr;
ASSERT_TRUE(svr.set_mount_point("/", "./www"));
svr.set_pre_routing_handler([](const Request &req, Response &res) {
if (req.path == "/dir/test.html") {
res.status = StatusCode::Unauthorized_401;
return Server::HandlerResponse::Handled;
}
return Server::HandlerResponse::Unhandled;
});
auto port = svr.bind_to_any_port(HOST);
ASSERT_LT(0, port);
thread t = thread([&] { svr.listen_after_bind(); });
auto se = detail::scope_exit([&] {
svr.stop();
t.join();
ASSERT_FALSE(svr.is_running());
});
svr.wait_until_ready();
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"}) {
SCOPED_TRACE(path);
auto res = cli.Get(path);
ASSERT_TRUE(res) << "Error: " << to_string(res.error());
EXPECT_EQ(StatusCode::NotFound_404, res->status);
}
}
#ifdef _WIN32 #ifdef _WIN32
// Issue #2435: mmap::open() must succeed even when another handle holds // Issue #2435: mmap::open() must succeed even when another handle holds
// the file open for writing (e.g. an active log file). // the file open for writing (e.g. an active log file).