UiServer (ix::HttpServer) : GET/HEAD, / -> index.html, MIME, en-têtes no-store/nosniff, vérification exclusive du port sous Windows. Chemins : décodage unique, refus de .., ., fichiers cachés, antislash, octet nul, : (lecteur ou flux NTFS), puis confinement vérifié par weakly_canonical. build_ui_url, build_core_ws_url, open_browser (http/https uniquement). 14 cas de test dont 12 tentatives d'évasion. Écrit par un subagent (DeepSeek V4.1 Flash), relu ; refus de : étendu aux flux NTFS.
303 lines
8.9 KiB
C++
303 lines
8.9 KiB
C++
// SPDX-License-Identifier: MPL-2.0
|
|
//
|
|
// Tests d'intégration : un vrai client HTTP (ix::HttpClient) contre UiServer, en boucle
|
|
// locale, avec des tentatives d'évasion de la racine qui doivent toutes échouer.
|
|
#include "sequa/studio/host/ui_server.hpp"
|
|
|
|
#include <doctest/doctest.h>
|
|
#include <ixwebsocket/IXHttp.h>
|
|
#include <ixwebsocket/IXHttpClient.h>
|
|
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <cstdint>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <memory>
|
|
#include <mutex>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <system_error>
|
|
#include <vector>
|
|
|
|
using namespace sequa::studio::host;
|
|
namespace fs = std::filesystem;
|
|
|
|
namespace {
|
|
|
|
constexpr std::string_view secret_content = "SECRET-NE-DOIT-JAMAIS-SORTIR";
|
|
|
|
// Dossier temporaire imposé par la tâche.
|
|
fs::path opencode_temp_dir()
|
|
{
|
|
#ifdef _WIN32
|
|
const fs::path fixed("C:/Users/a-mma/AppData/Local/Temp/opencode");
|
|
if (fs::is_directory(fixed)) {
|
|
return fixed;
|
|
}
|
|
#endif
|
|
return fs::temp_directory_path();
|
|
}
|
|
|
|
fs::path make_unique_dir()
|
|
{
|
|
static std::atomic<unsigned> counter{0};
|
|
const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count();
|
|
for (unsigned attempt = 0; attempt < 100; ++attempt) {
|
|
const fs::path dir = opencode_temp_dir() /
|
|
("sequa-host-web-" + std::to_string(stamp) + "-" +
|
|
std::to_string(counter.fetch_add(1)));
|
|
std::error_code ec;
|
|
if (fs::create_directories(dir, ec)) {
|
|
return dir;
|
|
}
|
|
}
|
|
return {};
|
|
}
|
|
|
|
void write_file(const fs::path& path, std::string_view content)
|
|
{
|
|
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
|
out.write(content.data(), static_cast<std::streamsize>(content.size()));
|
|
}
|
|
|
|
// Racine temporaire + serveur lancé sur un port libre de 19400-19499.
|
|
class TestEnv
|
|
{
|
|
public:
|
|
TestEnv() { setup(); }
|
|
~TestEnv()
|
|
{
|
|
server_.reset(); // arrête le serveur avant de supprimer les fichiers
|
|
std::error_code ec;
|
|
fs::remove_all(base_, ec);
|
|
}
|
|
|
|
TestEnv(const TestEnv&) = delete;
|
|
TestEnv& operator=(const TestEnv&) = delete;
|
|
|
|
bool started() const { return static_cast<bool>(server_); }
|
|
std::uint16_t port() const { return port_; }
|
|
const fs::path& root() const { return root_; }
|
|
const fs::path& base() const { return base_; }
|
|
|
|
std::vector<std::string> logs() const
|
|
{
|
|
const std::lock_guard<std::mutex> lock(mutex_);
|
|
return logs_;
|
|
}
|
|
|
|
private:
|
|
void setup()
|
|
{
|
|
base_ = make_unique_dir();
|
|
if (base_.empty()) {
|
|
return;
|
|
}
|
|
root_ = base_ / "root";
|
|
fs::create_directories(root_ / "assets");
|
|
write_file(root_ / "index.html", "<!doctype html><title>SEQUA</title>\n");
|
|
write_file(root_ / "assets" / "app.js", "console.log('ok');\n");
|
|
write_file(root_ / "assets" / "style.css", "body { color: red; }\n");
|
|
write_file(root_ / ".hidden", "cache\n");
|
|
write_file(base_ / "secret.txt", secret_content); // HORS de la racine
|
|
|
|
UiServerOptions options;
|
|
options.host = "127.0.0.1";
|
|
options.root = root_;
|
|
options.log = [this](std::string_view line) {
|
|
const std::lock_guard<std::mutex> lock(mutex_);
|
|
logs_.emplace_back(line);
|
|
};
|
|
for (int candidate = 19400; candidate <= 19499; ++candidate) {
|
|
options.port = static_cast<std::uint16_t>(candidate);
|
|
server_ = std::make_unique<UiServer>(options);
|
|
if (server_->start().empty()) {
|
|
port_ = static_cast<std::uint16_t>(candidate);
|
|
return;
|
|
}
|
|
server_.reset();
|
|
}
|
|
}
|
|
|
|
fs::path base_;
|
|
fs::path root_;
|
|
std::uint16_t port_ = 0;
|
|
mutable std::mutex mutex_;
|
|
std::vector<std::string> logs_;
|
|
std::unique_ptr<UiServer> server_; // détruit en premier : le journal reste valide
|
|
};
|
|
|
|
ix::HttpResponsePtr http_request(std::uint16_t port, const std::string& verb, const std::string& path)
|
|
{
|
|
ix::HttpClient client;
|
|
ix::HttpRequestArgsPtr args = client.createRequest();
|
|
args->followRedirects = false;
|
|
const std::string url = "http://127.0.0.1:" + std::to_string(port) + path;
|
|
if (verb == "GET") {
|
|
return client.get(url, args);
|
|
}
|
|
if (verb == "HEAD") {
|
|
return client.head(url, args);
|
|
}
|
|
return client.post(url, std::string("x"), args);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("Web : GET / sert index.html avec les bons en-tetes")
|
|
{
|
|
TestEnv env;
|
|
REQUIRE(env.started());
|
|
|
|
const ix::HttpResponsePtr response = http_request(env.port(), "GET", "/");
|
|
REQUIRE(response);
|
|
CHECK(response->statusCode == 200);
|
|
CHECK(response->body.find("SEQUA") != std::string::npos);
|
|
CHECK(response->headers["Content-Type"] == "text/html; charset=utf-8");
|
|
CHECK(response->headers["Cache-Control"] == "no-store");
|
|
CHECK(response->headers["X-Content-Type-Options"] == "nosniff");
|
|
CHECK(response->headers["Referrer-Policy"] == "no-referrer");
|
|
}
|
|
|
|
TEST_CASE("Web : la query est ignoree pour trouver le fichier")
|
|
{
|
|
TestEnv env;
|
|
REQUIRE(env.started());
|
|
|
|
const ix::HttpResponsePtr response = http_request(env.port(), "GET", "/?core=ws%3A%2F%2Fx");
|
|
REQUIRE(response);
|
|
CHECK(response->statusCode == 200);
|
|
CHECK(response->body.find("SEQUA") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("Web : types MIME des assets")
|
|
{
|
|
TestEnv env;
|
|
REQUIRE(env.started());
|
|
|
|
const ix::HttpResponsePtr js = http_request(env.port(), "GET", "/assets/app.js");
|
|
REQUIRE(js);
|
|
CHECK(js->statusCode == 200);
|
|
CHECK(js->headers["Content-Type"] == "text/javascript; charset=utf-8");
|
|
|
|
const ix::HttpResponsePtr css = http_request(env.port(), "GET", "/assets/style.css");
|
|
REQUIRE(css);
|
|
CHECK(css->statusCode == 200);
|
|
CHECK(css->headers["Content-Type"] == "text/css; charset=utf-8");
|
|
}
|
|
|
|
TEST_CASE("Web : HEAD renvoie les en-tetes sans corps")
|
|
{
|
|
TestEnv env;
|
|
REQUIRE(env.started());
|
|
|
|
const ix::HttpResponsePtr response = http_request(env.port(), "HEAD", "/");
|
|
REQUIRE(response);
|
|
CHECK(response->statusCode == 200);
|
|
CHECK(response->headers["Content-Type"] == "text/html; charset=utf-8");
|
|
CHECK(response->headers["Content-Length"] == "0");
|
|
}
|
|
|
|
TEST_CASE("Web : POST -> 405 avec Allow")
|
|
{
|
|
TestEnv env;
|
|
REQUIRE(env.started());
|
|
|
|
const ix::HttpResponsePtr response = http_request(env.port(), "POST", "/");
|
|
REQUIRE(response);
|
|
CHECK(response->statusCode == 405);
|
|
CHECK(response->headers["Allow"] == "GET, HEAD");
|
|
}
|
|
|
|
TEST_CASE("Web : fichier absent -> 404")
|
|
{
|
|
TestEnv env;
|
|
REQUIRE(env.started());
|
|
|
|
const ix::HttpResponsePtr response = http_request(env.port(), "GET", "/absent.js");
|
|
REQUIRE(response);
|
|
CHECK(response->statusCode == 404);
|
|
}
|
|
|
|
TEST_CASE("Web : tentatives d'evasion refusees sans jamais livrer le secret")
|
|
{
|
|
TestEnv env;
|
|
REQUIRE(env.started());
|
|
|
|
const std::vector<std::string> evasions = {
|
|
"/../secret.txt",
|
|
"/%2e%2e/secret.txt",
|
|
"/%2E%2E%2Fsecret.txt",
|
|
"/..%5csecret.txt",
|
|
"/assets/../../secret.txt",
|
|
"/%252e%252e/secret.txt",
|
|
"/C:/Windows/win.ini",
|
|
"//etc/passwd",
|
|
"/.hidden",
|
|
"/%00",
|
|
"/index.html:secret",
|
|
"/index.html::$DATA",
|
|
};
|
|
for (const std::string& path : evasions) {
|
|
CAPTURE(path);
|
|
const ix::HttpResponsePtr response = http_request(env.port(), "GET", path);
|
|
REQUIRE(response);
|
|
CHECK(response->statusCode == 404);
|
|
CHECK(response->body.find("SECRET") == std::string::npos);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("Web : journal une ligne par requete")
|
|
{
|
|
TestEnv env;
|
|
REQUIRE(env.started());
|
|
|
|
(void)http_request(env.port(), "GET", "/");
|
|
(void)http_request(env.port(), "GET", "/absent.js");
|
|
(void)http_request(env.port(), "POST", "/");
|
|
|
|
const std::vector<std::string> logs = env.logs();
|
|
auto contains = [&logs](const std::string& needle) {
|
|
for (const std::string& line : logs) {
|
|
if (line.find(needle) != std::string::npos) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
CHECK(contains("GET / 200"));
|
|
CHECK(contains("GET /absent.js 404"));
|
|
CHECK(contains("POST / 405"));
|
|
}
|
|
|
|
TEST_CASE("Web : port deja occupe -> start() echoue")
|
|
{
|
|
TestEnv env;
|
|
REQUIRE(env.started());
|
|
|
|
UiServerOptions options;
|
|
options.host = "127.0.0.1";
|
|
options.port = env.port();
|
|
options.root = env.root();
|
|
UiServer second(options);
|
|
CHECK_FALSE(second.start().empty());
|
|
CHECK_FALSE(second.running());
|
|
}
|
|
|
|
TEST_CASE("Web : racine absente -> erreur claire")
|
|
{
|
|
const fs::path missing =
|
|
opencode_temp_dir() / "sequa-host-web-inexistant-doit-echouer";
|
|
|
|
UiServerOptions options;
|
|
options.host = "127.0.0.1";
|
|
options.port = 19499;
|
|
options.root = missing;
|
|
UiServer server(options);
|
|
|
|
const std::string error = server.start();
|
|
CHECK(error.find("studio/ui/dist introuvable") != std::string::npos);
|
|
CHECK_FALSE(server.running());
|
|
}
|