From a4a84dc16694110189ce0b12a5181a2cfe083506 Mon Sep 17 00:00:00 2001 From: Benjamin Sergeant Date: Thu, 10 Sep 2026 12:17:01 -0700 Subject: [PATCH] Fix: path traversal in HttpServer default callback (CVE candidate) The default HttpServer callback built the filesystem path as "." + uri without sanitizing .. segments, allowing a remote unauthenticated client to read files outside the document root with a request like GET /../secret.txt. Add sanitizeUri() which resolves . and .. lexically, clamping any attempt to escape above the virtual root so the path stays within the document root directory. Reported by Yeongtaek Yoo (GitHub: @yt010108). Co-Authored-By: Claude Sonnet 4.6 --- ixwebsocket/IXHttpServer.cpp | 45 ++++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/ixwebsocket/IXHttpServer.cpp b/ixwebsocket/IXHttpServer.cpp index 633b8768..507edc52 100644 --- a/ixwebsocket/IXHttpServer.cpp +++ b/ixwebsocket/IXHttpServer.cpp @@ -43,19 +43,54 @@ namespace return std::make_pair(res.first, std::string(vec.begin(), vec.end())); } - std::string response_head_file(const std::string& file_name){ + // Normalize a URI by resolving . and .. segments so that the resulting + // path never escapes the document root. Attempts to traverse above / + // (e.g. GET /../secret) are silently clamped rather than rejected so that + // the caller receives a predictable 404 instead of a server error. + std::string sanitizeUri(const std::string& uri) + { + std::vector parts; + std::string token; + std::istringstream stream(uri); + + while (std::getline(stream, token, '/')) + { + if (token == "..") + { + if (!parts.empty()) parts.pop_back(); + // else: silently ignore attempts to escape above root + } + else if (!token.empty() && token != ".") + { + parts.push_back(token); + } + } + + std::string sanitized; + for (const auto& part : parts) + { + sanitized += "/" + part; + } - if (std::string::npos != file_name.find(".html") || std::string::npos != file_name.find(".htm")) + return sanitized.empty() ? "/" : sanitized; + } + + std::string response_head_file(const std::string& file_name) + { + if (std::string::npos != file_name.find(".html") || + std::string::npos != file_name.find(".htm")) return "text/html"; else if (std::string::npos != file_name.find(".css")) return "text/css"; - else if (std::string::npos != file_name.find(".js") || std::string::npos != file_name.find(".mjs")) + else if (std::string::npos != file_name.find(".js") || + std::string::npos != file_name.find(".mjs")) return "application/x-javascript"; else if (std::string::npos != file_name.find(".ico")) return "image/x-icon"; else if (std::string::npos != file_name.find(".png")) return "image/png"; - else if (std::string::npos != file_name.find(".jpg") || std::string::npos != file_name.find(".jpeg")) + else if (std::string::npos != file_name.find(".jpg") || + std::string::npos != file_name.find(".jpeg")) return "image/jpeg"; else if (std::string::npos != file_name.find(".gif")) return "image/gif"; @@ -125,7 +160,7 @@ namespace ix [this](HttpRequestPtr request, std::shared_ptr connectionState) -> HttpResponsePtr { - std::string uri(request->uri); + std::string uri(sanitizeUri(request->uri)); if (uri.empty() || uri == "/") { uri = "/index.html";