From f787f31b870fe75db67f008d523ee2d2feb69367 Mon Sep 17 00:00:00 2001 From: yhirose Date: Fri, 13 Mar 2026 16:22:16 -0400 Subject: [PATCH] Implement symlink protection in static file server and add corresponding tests --- README.md | 5 +++++ httplib.h | 50 +++++++++++++++++++++++++++++++++++++++++++++++++- test/test.cc | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2986716..9963f55 100644 --- a/README.md +++ b/README.md @@ -350,6 +350,11 @@ The following are built-in mappings: > [!WARNING] > These static file server methods are not thread-safe. + + +> [!NOTE] +> On POSIX systems, the static file server rejects requests that resolve (via symlinks) to a path outside the mounted base directory. Ensure that the served directory has appropriate permissions, as managing access to the served directory is the application developer's responsibility. + ### File request handler ```cpp diff --git a/httplib.h b/httplib.h index fd843a0..45a2fbe 100644 --- a/httplib.h +++ b/httplib.h @@ -1773,6 +1773,7 @@ private: struct MountPointEntry { std::string mount_point; std::string base_dir; + std::string resolved_base_dir; Headers headers; }; std::vector base_dirs_; @@ -4836,6 +4837,30 @@ inline bool is_valid_path(const std::string &path) { return true; } +inline bool canonicalize_path(const char *path, std::string &resolved) { +#if defined(_WIN32) + char buf[_MAX_PATH]; + if (_fullpath(buf, path, _MAX_PATH) == nullptr) { return false; } + resolved = buf; +#else + char buf[PATH_MAX]; + if (realpath(path, buf) == nullptr) { return false; } + resolved = buf; +#endif + return true; +} + +inline bool is_path_within_base(const std::string &resolved_path, + const std::string &resolved_base) { +#if defined(_WIN32) + return _strnicmp(resolved_path.c_str(), resolved_base.c_str(), + resolved_base.size()) == 0; +#else + return strncmp(resolved_path.c_str(), resolved_base.c_str(), + resolved_base.size()) == 0; +#endif +} + inline FileStat::FileStat(const std::string &path) { #if defined(_WIN32) auto wpath = u8string_to_wstring(path.c_str()); @@ -10550,7 +10575,18 @@ inline bool Server::set_mount_point(const std::string &mount_point, if (stat.is_dir()) { std::string mnt = !mount_point.empty() ? mount_point : "/"; if (!mnt.empty() && mnt[0] == '/') { - base_dirs_.push_back({std::move(mnt), dir, std::move(headers)}); + std::string resolved_base; + if (detail::canonicalize_path(dir.c_str(), resolved_base)) { +#if defined(_WIN32) + if (resolved_base.back() != '\\' && resolved_base.back() != '/') { + resolved_base += '\\'; + } +#else + if (resolved_base.back() != '/') { resolved_base += '/'; } +#endif + } + base_dirs_.push_back( + {std::move(mnt), dir, std::move(resolved_base), std::move(headers)}); return true; } } @@ -11130,6 +11166,18 @@ inline bool Server::handle_file_request(Request &req, Response &res) { auto path = entry.base_dir + sub_path; if (path.back() == '/') { path += "index.html"; } + // Defense-in-depth: is_valid_path blocks ".." traversal in the URL, + // but symlinks/junctions can still escape the base directory. + if (!entry.resolved_base_dir.empty()) { + std::string resolved_path; + if (detail::canonicalize_path(path.c_str(), resolved_path) && + !detail::is_path_within_base(resolved_path, + entry.resolved_base_dir)) { + res.status = StatusCode::Forbidden_403; + return true; + } + } + detail::FileStat stat(path); if (stat.is_dir()) { diff --git a/test/test.cc b/test/test.cc index a1307d5..408304e 100644 --- a/test/test.cc +++ b/test/test.cc @@ -17076,3 +17076,50 @@ TEST_F(WebSocketSSLIntegrationTest, TextEcho) { client.close(); } #endif + +#if !defined(_WIN32) +TEST(SymlinkTest, SymlinkEscapeFromBaseDirectory) { + auto secret_dir = std::string("./symlink_test_secret"); + auto served_dir = std::string("./symlink_test_served"); + auto secret_file = secret_dir + "/secret.txt"; + auto symlink_path = served_dir + "/escape"; + + // Setup: create directories and files + mkdir(secret_dir.c_str(), 0755); + mkdir(served_dir.c_str(), 0755); + + { + std::ofstream ofs(secret_file); + ofs << "SECRET_DATA"; + } + + // Create symlink using absolute path so it resolves correctly + char abs_secret[PATH_MAX]; + ASSERT_NE(nullptr, realpath(secret_dir.c_str(), abs_secret)); + ASSERT_EQ(0, symlink(abs_secret, symlink_path.c_str())); + + auto se = detail::scope_exit([&] { + unlink(symlink_path.c_str()); + unlink(secret_file.c_str()); + rmdir(served_dir.c_str()); + rmdir(secret_dir.c_str()); + }); + + Server svr; + svr.set_mount_point("/", served_dir); + + auto listen_thread = std::thread([&svr]() { svr.listen("localhost", PORT); }); + auto se2 = detail::scope_exit([&] { + svr.stop(); + listen_thread.join(); + }); + svr.wait_until_ready(); + + Client cli("localhost", PORT); + + // Symlink pointing outside base dir should be blocked + auto res = cli.Get("/escape/secret.txt"); + ASSERT_TRUE(res); + EXPECT_EQ(StatusCode::Forbidden_403, res->status); +} +#endif