|
@@ -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_;
|
|
@@ -2915,6 +2916,8 @@ std::string encode_query_component(const std::string &component,
|
|
|
std::string decode_query_component(const std::string &component,
|
|
std::string decode_query_component(const std::string &component,
|
|
|
bool plus_as_space = true);
|
|
bool plus_as_space = true);
|
|
|
|
|
|
|
|
|
|
+std::string sanitize_filename(const std::string &filename);
|
|
|
|
|
+
|
|
|
std::string append_query_params(const std::string &path, const Params ¶ms);
|
|
std::string append_query_params(const std::string &path, const Params ¶ms);
|
|
|
|
|
|
|
|
std::pair<std::string, std::string> make_range_header(const Ranges &ranges);
|
|
std::pair<std::string, std::string> make_range_header(const Ranges &ranges);
|
|
@@ -4834,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());
|
|
@@ -9290,7 +9317,8 @@ inline std::string decode_path_component(const std::string &component) {
|
|
|
// Unicode %uXXXX encoding
|
|
// Unicode %uXXXX encoding
|
|
|
auto val = 0;
|
|
auto val = 0;
|
|
|
if (detail::from_hex_to_i(component, i + 2, 4, val)) {
|
|
if (detail::from_hex_to_i(component, i + 2, 4, val)) {
|
|
|
- // 4 digits Unicode codes
|
|
|
|
|
|
|
+ // 4 digits Unicode codes: val is 0x0000-0xFFFF (from 4 hex digits),
|
|
|
|
|
+ // so to_utf8 writes at most 3 bytes. buff[4] is safe.
|
|
|
char buff[4];
|
|
char buff[4];
|
|
|
size_t len = detail::to_utf8(val, buff);
|
|
size_t len = detail::to_utf8(val, buff);
|
|
|
if (len > 0) { result.append(buff, len); }
|
|
if (len > 0) { result.append(buff, len); }
|
|
@@ -9395,6 +9423,30 @@ inline std::string decode_query_component(const std::string &component,
|
|
|
return result;
|
|
return result;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+inline std::string sanitize_filename(const std::string &filename) {
|
|
|
|
|
+ // Extract basename: find the last path separator (/ or \)
|
|
|
|
|
+ auto pos = filename.find_last_of("/\\");
|
|
|
|
|
+ auto result =
|
|
|
|
|
+ (pos != std::string::npos) ? filename.substr(pos + 1) : filename;
|
|
|
|
|
+
|
|
|
|
|
+ // Strip null bytes
|
|
|
|
|
+ result.erase(std::remove(result.begin(), result.end(), '\0'), result.end());
|
|
|
|
|
+
|
|
|
|
|
+ // Trim whitespace
|
|
|
|
|
+ {
|
|
|
|
|
+ auto start = result.find_first_not_of(" \t");
|
|
|
|
|
+ auto end = result.find_last_not_of(" \t");
|
|
|
|
|
+ result = (start == std::string::npos)
|
|
|
|
|
+ ? ""
|
|
|
|
|
+ : result.substr(start, end - start + 1);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Reject . and ..
|
|
|
|
|
+ if (result == "." || result == "..") { return ""; }
|
|
|
|
|
+
|
|
|
|
|
+ return result;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
inline std::string append_query_params(const std::string &path,
|
|
inline std::string append_query_params(const std::string &path,
|
|
|
const Params ¶ms) {
|
|
const Params ¶ms) {
|
|
|
std::string path_with_query = path;
|
|
std::string path_with_query = path;
|
|
@@ -10523,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;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -11103,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()) {
|