mirror of
https://github.com/yhirose/cpp-httplib.git
synced 2026-10-15 10:39:37 +00:00
Implement symlink protection in static file server and add corresponding tests
This commit is contained in:
1 parent
43a54a3e3d
commit
f787f31b87
3 files changed
+101
-1
No files matched your search
@@ -350,6 +350,11 @@ The following are built-in mappings:
|
|||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> These static file server methods are not thread-safe.
|
> 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
|
### File request handler
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
|
|||||||
@@ -1773,6 +1773,7 @@ private:
|
|||||||
struct MountPointEntry {
|
struct MountPointEntry {
|
||||||
std::string mount_point;
|
std::string mount_point;
|
||||||
std::string base_dir;
|
std::string base_dir;
|
||||||
|
std::string resolved_base_dir;
|
||||||
Headers headers;
|
Headers headers;
|
||||||
};
|
};
|
||||||
std::vector<MountPointEntry> base_dirs_;
|
std::vector<MountPointEntry> base_dirs_;
|
||||||
@@ -4836,6 +4837,30 @@ inline bool is_valid_path(const std::string &path) {
|
|||||||
return true;
|
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) {
|
inline FileStat::FileStat(const std::string &path) {
|
||||||
#if defined(_WIN32)
|
#if defined(_WIN32)
|
||||||
auto wpath = u8string_to_wstring(path.c_str());
|
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()) {
|
if (stat.is_dir()) {
|
||||||
std::string mnt = !mount_point.empty() ? mount_point : "/";
|
std::string mnt = !mount_point.empty() ? mount_point : "/";
|
||||||
if (!mnt.empty() && mnt[0] == '/') {
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11130,6 +11166,18 @@ inline bool Server::handle_file_request(Request &req, Response &res) {
|
|||||||
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"; }
|
||||||
|
|
||||||
|
// 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);
|
detail::FileStat stat(path);
|
||||||
|
|
||||||
if (stat.is_dir()) {
|
if (stat.is_dir()) {
|
||||||
|
|||||||
@@ -17076,3 +17076,50 @@ TEST_F(WebSocketSSLIntegrationTest, TextEcho) {
|
|||||||
client.close();
|
client.close();
|
||||||
}
|
}
|
||||||
#endif
|
#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
|
||||||
Reference in new issue
Block a user