diff --git a/studio/host/web/CMakeLists.txt b/studio/host/web/CMakeLists.txt new file mode 100644 index 0000000..4530503 --- /dev/null +++ b/studio/host/web/CMakeLists.txt @@ -0,0 +1,32 @@ +# SPDX-License-Identifier: MPL-2.0 +# +# sequa::host_web : sert studio/ui/dist en HTTP local et ouvre le navigateur. +# Dépendance privée : ixwebsocket::ixwebsocket (serveur HTTP). ws2_32 sous Windows +# pour la vérification exclusive du port (voir core/src/server/server.cpp). + +add_library(sequa_host_web + src/ui_server.cpp + src/urls.cpp + src/browser.cpp) +add_library(sequa::host_web ALIAS sequa_host_web) + +target_include_directories(sequa_host_web + PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_compile_features(sequa_host_web PUBLIC cxx_std_20) +target_link_libraries(sequa_host_web PRIVATE ixwebsocket::ixwebsocket) +if(WIN32) + target_link_libraries(sequa_host_web PRIVATE ws2_32) # vérification exclusive du port +endif() +sequa_target_defaults(sequa_host_web) + +if(SEQUA_BUILD_TESTS) + add_executable(sequa_host_web_tests + tests/main.cpp + tests/test_ui_server.cpp + tests/test_urls.cpp + tests/test_browser.cpp) + target_link_libraries(sequa_host_web_tests PRIVATE + sequa::host_web doctest::doctest ixwebsocket::ixwebsocket) + sequa_target_defaults(sequa_host_web_tests) + doctest_discover_tests(sequa_host_web_tests) +endif() diff --git a/studio/host/web/README.md b/studio/host/web/README.md new file mode 100644 index 0000000..ef8b623 --- /dev/null +++ b/studio/host/web/README.md @@ -0,0 +1,76 @@ +# `sequa::host_web` — service HTTP de l'interface et ouverture du navigateur + +Brique `web` du Studio host (`studio/host/web/`). Elle sert l'interface construite +(`studio/ui/dist`) en HTTP local et ouvre le navigateur de l'auteur. Références : +[ADR 0006](../../../docs/adr/0006-studio-host-et-cycle-de-vie-du-core.md) et +[spécification Studio - Core 0.2](../../../docs/specs/protocole-studio-core-0.2.md) §7. + +## Rôle + +- `UiServer` : serveur HTTP statique en lecture seule (GET et HEAD) sur `127.0.0.1:9180` + par défaut. `"/"` sert `index.html`, la query est ignorée pour trouver le fichier. +- `urls.hpp` : construction de l'URL de l'interface (`?core=`) et + de l'URL WebSocket du Core (`ws://host:port/?token=…`), encodage pourcentage RFC 3986. +- `browser.hpp` : validation stricte des URL (http/https uniquement) et ouverture du + navigateur (ShellExecuteW sous Windows, `open` sous macOS, `xdg-open` sous Linux). + +## API publique + +```cpp +struct UiServerOptions { + std::string host = "127.0.0.1"; + std::uint16_t port = 9180; + std::filesystem::path root; // studio/ui/dist + std::function log; // une ligne par requête +}; + +class UiServer { +public: + explicit UiServer(UiServerOptions options); + ~UiServer(); // stop() propre + std::string start(); // "" si OK, sinon la raison + void stop(); + bool running() const noexcept; + const UiServerOptions& options() const noexcept; +}; + +std::string percent_encode(std::string_view value); +std::string build_ui_url(std::string_view ui_base_url, std::string_view core_ws_url); +std::string build_core_ws_url(std::string_view host, std::uint16_t port, std::string_view token); + +bool is_openable_url(std::string_view url); +bool open_browser(std::string_view url); +``` + +## Sécurité des chemins + +`request->uri` d'IXWebSocket **n'est pas** décodée par la bibliothèque : c'est `UiServer` +qui décode le pourcentage une seule fois, puis refuse (404, jamais 500) : + +- octet nul (`%00`), antislash, segments `.` ou `..`, segments vides (`//`), chemin + absolu, lettre de lecteur (`C:`), segments commençant par `.` (fichiers cachés), + double décodage (`%252e` laisse un triplet après le premier décodage). + +Le fichier est ensuite résolu par `std::filesystem::weakly_canonical` et doit rester +**dans la racine canonique** (protection contre les liens symboliques) et être un fichier +régulier. Ce contrôle est plus strict que le rappel par défaut d'IXWebSocket, qui se +contente de rogner les `..` et ne décode jamais l'URI. + +Sous Windows, `start()` vérifie en plus le port de façon exclusive +(`SO_EXCLUSIVEADDRUSE`) avant d'écouter : IXWebSocket active `SO_REUSEADDR`, qui +autoriserait sinon deux processus à écouter le même port. La technique est reprise de +`core/src/server/server.cpp`. + +En-têtes renvoyés systématiquement : `Content-Type`, `Cache-Control: no-store`, +`X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer` (et `Allow` sur 405). + +## Tests + +`sequa_host_web_tests` (doctest) : types MIME, HEAD, 405, 404, query ignorée, journal, +port occupé, racine absente, tests purs des URL, et dix tentatives d'évasion qui doivent +toutes renvoyer 404 sans jamais livrer le fichier secret placé hors de la racine. + +## Licences + +- Ce code : MPL 2.0 (voir l'en-tête SPDX de chaque fichier). +- IXWebSocket : BSD-3-Clause. diff --git a/studio/host/web/include/sequa/studio/host/browser.hpp b/studio/host/web/include/sequa/studio/host/browser.hpp new file mode 100644 index 0000000..b65d9fb --- /dev/null +++ b/studio/host/web/include/sequa/studio/host/browser.hpp @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MPL-2.0 +// +// Ouverture du navigateur du système pour l'interface servie localement (ADR 0006). +#pragma once + +#include + +namespace sequa::studio::host { + +// Vrai pour une URL http:// ou https:// bien formée (schéma insensible à la casse, +// aucun caractère de contrôle). Sert de garde anti-injection avant open_browser(). +bool is_openable_url(std::string_view url); + +// Ouvre l'URL dans le navigateur par défaut : ShellExecuteW (Windows), `open` (macOS), +// `xdg-open` (Linux, via posix_spawnp, sans shell). Refuse toute URL non http(s). +bool open_browser(std::string_view url); + +} // namespace sequa::studio::host diff --git a/studio/host/web/include/sequa/studio/host/ui_server.hpp b/studio/host/web/include/sequa/studio/host/ui_server.hpp new file mode 100644 index 0000000..8a9db93 --- /dev/null +++ b/studio/host/web/include/sequa/studio/host/ui_server.hpp @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MPL-2.0 +// +// UiServer : sert l'interface construite (studio/ui/dist) en HTTP local, en lecture +// seule (GET et HEAD). Voir docs/specs/protocole-studio-core-0.2.md §7 et ADR 0006. +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace sequa::studio::host { + +struct UiServerOptions +{ + // Écoute locale par défaut (ADR 0006, spécification §1). + std::string host = "127.0.0.1"; + std::uint16_t port = 9180; + // Dossier racine servi (défaut applicatif : studio/ui/dist). + std::filesystem::path root; + // Journal facultatif : une ligne par requête (« méthode chemin statut »). + std::function log; +}; + +// Serveur HTTP statique de l'interface. start() renvoie la chaîne vide en cas de +// succès, sinon une raison lisible. L'objet se nettoie dans son destructeur. +class UiServer +{ +public: + explicit UiServer(UiServerOptions options); + ~UiServer(); + + UiServer(const UiServer&) = delete; + UiServer& operator=(const UiServer&) = delete; + + std::string start(); + void stop(); + bool running() const noexcept; + const UiServerOptions& options() const noexcept; + +private: + struct Impl; + std::unique_ptr impl_; +}; + +} // namespace sequa::studio::host diff --git a/studio/host/web/include/sequa/studio/host/urls.hpp b/studio/host/web/include/sequa/studio/host/urls.hpp new file mode 100644 index 0000000..a26fa1d --- /dev/null +++ b/studio/host/web/include/sequa/studio/host/urls.hpp @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MPL-2.0 +// +// Construction des URL du Studio : page de l'interface portant l'URL WebSocket du Core +// (spécification §7.2 étape 3) et URL ws:// du Core avec son jeton (spécification §3). +#pragma once + +#include +#include +#include + +namespace sequa::studio::host { + +// Encodage pourcentage RFC 3986 : tout est encodé sauf les non-réservés A-Z a-z 0-9 - . _ ~. +std::string percent_encode(std::string_view value); + +// ui_base_url + "/?core=" + core_ws_url encodée. +std::string build_ui_url(std::string_view ui_base_url, std::string_view core_ws_url); + +// "ws://host:port/?token=". +std::string build_core_ws_url(std::string_view host, std::uint16_t port, std::string_view token); + +} // namespace sequa::studio::host diff --git a/studio/host/web/src/browser.cpp b/studio/host/web/src/browser.cpp new file mode 100644 index 0000000..ea493ac --- /dev/null +++ b/studio/host/web/src/browser.cpp @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: MPL-2.0 +// +// Ouverture du navigateur par défaut. Aucune URL autre que http(s) n'est acceptée, +// et aucun interpréteur de commandes n'est utilisé (pas d'injection possible). +#include "sequa/studio/host/browser.hpp" + +#include +#include +#include +#include + +#ifdef _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#else +#include +#include + +extern char** environ; +#endif + +namespace sequa::studio::host { + +namespace { + +bool starts_with_ci_case(std::string_view value, std::string_view prefix) +{ + if (value.size() < prefix.size()) { + return false; + } + for (std::size_t i = 0; i < prefix.size(); ++i) { + if (std::tolower(static_cast(value[i])) != + std::tolower(static_cast(prefix[i]))) { + return false; + } + } + return true; +} + +} // namespace + +bool is_openable_url(std::string_view url) +{ + if (!starts_with_ci_case(url, "http://") && !starts_with_ci_case(url, "https://")) { + return false; + } + for (const char raw : url) { + const unsigned char c = static_cast(raw); + if (c <= 0x20 || c == 0x7F) { + return false; // espace ou caractère de contrôle : refusé + } + } + return true; +} + +bool open_browser(std::string_view url) +{ + if (!is_openable_url(url)) { + return false; + } + const std::string value(url); + +#ifdef _WIN32 + // UTF-8 -> UTF-16 correctement, y compris pour les accents du chemin. + const int wide_length = + ::MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast(value.size()), nullptr, 0); + if (wide_length <= 0) { + return false; + } + std::wstring wide(static_cast(wide_length), L'\0'); + if (::MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast(value.size()), wide.data(), + wide_length) <= 0) { + return false; + } + const HINSTANCE result = ::ShellExecuteW(nullptr, L"open", wide.c_str(), nullptr, nullptr, SW_SHOWNORMAL); + return reinterpret_cast(result) > 32; +#else +#if defined(__APPLE__) + const char* program = "open"; +#else + const char* program = "xdg-open"; +#endif + pid_t pid = 0; + char* argv[] = {const_cast(program), const_cast(value.c_str()), nullptr}; + const int rc = ::posix_spawnp(&pid, program, nullptr, nullptr, argv, environ); + return rc == 0; +#endif +} + +} // namespace sequa::studio::host diff --git a/studio/host/web/src/ui_server.cpp b/studio/host/web/src/ui_server.cpp new file mode 100644 index 0000000..8f8e5fe --- /dev/null +++ b/studio/host/web/src/ui_server.cpp @@ -0,0 +1,420 @@ +// SPDX-License-Identifier: MPL-2.0 +// +// Serveur HTTP statique de l'interface (studio/ui/dist). Lecture seule (GET, HEAD), +// sécurisation stricte des chemins : décodage pourcentage unique puis vérification +// que le fichier réel reste dans la racine (protection contre les liens symboliques). +#include "sequa/studio/host/ui_server.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#endif + +namespace sequa::studio::host { + +namespace { + +#ifdef _WIN32 +// IXWebSocket active SO_REUSEADDR sur sa socket d'écoute. Sous Windows, cette option +// permet à un second processus d'écouter sur le MÊME port (répartition imprévisible des +// connexions) au lieu d'échouer. On vérifie donc d'abord que le port est libre avec une +// réservation exclusive temporaire. Un intervalle de concurrence subsiste entre cette +// vérification et l'écoute réelle : acceptable pour un serveur de développement local. +// Technique copiée de core/src/server/server.cpp (probe_port_exclusive). +std::string probe_port_exclusive(const std::string& host, int port) +{ + const SOCKET probe = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (probe == INVALID_SOCKET) { + return "Création de socket impossible (WSA " + std::to_string(WSAGetLastError()) + ")."; + } + BOOL exclusive = TRUE; + ::setsockopt(probe, SOL_SOCKET, SO_EXCLUSIVEADDRUSE, reinterpret_cast(&exclusive), + sizeof(exclusive)); + + sockaddr_in address{}; + address.sin_family = AF_INET; + address.sin_port = ::htons(static_cast(port)); + if (::inet_pton(AF_INET, host.c_str(), &address.sin_addr) != 1) { + ::closesocket(probe); + return "Adresse IPv4 invalide : " + host; + } + + const int result = ::bind(probe, reinterpret_cast(&address), sizeof(address)); + const int error = WSAGetLastError(); + ::closesocket(probe); + if (result != 0) { + return "Port " + std::to_string(port) + " déjà utilisé sur " + host + " (WSA " + std::to_string(error) + ")."; + } + return {}; +} +#endif + +int hex_value(char c) +{ + if (c >= '0' && c <= '9') { + return c - '0'; + } + if (c >= 'a' && c <= 'f') { + return c - 'a' + 10; + } + if (c >= 'A' && c <= 'F') { + return c - 'A' + 10; + } + return -1; +} + +// Une séquence %XX encore présente dans le résultat signifie un double encodage +// (ex. %252e se décode une fois en %2e, qui reste décodable en « . ») : refusé. +bool has_percent_triplet(std::string_view value) +{ + for (std::size_t i = 0; i + 2 < value.size(); ++i) { + if (value[i] == '%' && hex_value(value[i + 1]) >= 0 && hex_value(value[i + 2]) >= 0) { + return true; + } + } + return false; +} + +// Décodage pourcentage UNE seule fois. Faux si une séquence %XX est mal formée. +bool percent_decode_once(std::string_view input, std::string& output) +{ + output.clear(); + output.reserve(input.size()); + for (std::size_t i = 0; i < input.size(); ++i) { + const char c = input[i]; + if (c != '%') { + output.push_back(c); + continue; + } + if (i + 2 >= input.size()) { + return false; + } + const int high = hex_value(input[i + 1]); + const int low = hex_value(input[i + 2]); + if (high < 0 || low < 0) { + return false; + } + output.push_back(static_cast((high << 4) | low)); + i += 2; + } + return true; +} + +// Valide la cible de requête et renvoie le chemin relatif à la racine, ou nullopt si +// refusé (octet nul, antislash, « .. », « . », chemin absolu, lettre de lecteur, +// fichier caché, segment vide, double décodage). +std::optional safe_relative_path(std::string_view raw_path) +{ + std::string decoded; + if (!percent_decode_once(raw_path, decoded)) { + return std::nullopt; + } + if (decoded.find('\0') != std::string::npos || decoded.find('\\') != std::string::npos) { + return std::nullopt; + } + if (decoded.empty() || decoded.front() != '/') { + return std::nullopt; + } + if (has_percent_triplet(decoded)) { + return std::nullopt; // double décodage + } + + std::string_view rest(decoded); + rest.remove_prefix(1); + if (!rest.empty() && rest.front() == '/') { + return std::nullopt; // « //... » : chemin réseau/absolu + } + if (rest.empty()) { + return std::filesystem::path("index.html"); + } + + std::filesystem::path relative; + std::size_t start = 0; + while (start <= rest.size()) { + const std::size_t slash = rest.find('/', start); + const std::string_view segment = + slash == std::string_view::npos ? rest.substr(start) : rest.substr(start, slash - start); + if (segment.empty()) { + return std::nullopt; // segment vide : « /a//b » ou « /a/ » + } + if (segment == "." || segment == "..") { + return std::nullopt; + } + if (segment.front() == '.') { + return std::nullopt; // fichier caché + } + if (segment.find(':') != std::string_view::npos) { + return std::nullopt; // lettre de lecteur (C:) ou flux NTFS (« fichier:flux ») + } + relative /= std::filesystem::path(std::string(segment)); + if (slash == std::string_view::npos) { + break; + } + start = slash + 1; + } + return relative; +} + +// Vrai si « candidate » est identique à « root » ou situé sous « root » (comparaison +// composant par composant, sans piège de préfixe textuel). +bool is_within(const std::filesystem::path& root, const std::filesystem::path& candidate) +{ + auto root_it = root.begin(); + auto candidate_it = candidate.begin(); + for (; root_it != root.end(); ++root_it, ++candidate_it) { + if (candidate_it == candidate.end() || *candidate_it != *root_it) { + return false; + } + } + return true; +} + +// Résout la cible vers un fichier régulier DANS la racine, ou nullopt. +std::optional resolve_file(const std::filesystem::path& root_canonical, + std::string_view raw_path) +{ + const std::optional relative = safe_relative_path(raw_path); + if (!relative) { + return std::nullopt; + } + std::error_code ec; + const std::filesystem::path candidate = + std::filesystem::weakly_canonical(root_canonical / *relative, ec); + if (ec || !is_within(root_canonical, candidate)) { + return std::nullopt; + } + if (!std::filesystem::is_regular_file(candidate, ec) || ec) { + return std::nullopt; + } + return candidate; +} + +bool read_file(const std::filesystem::path& path, std::string& content) +{ + std::ifstream file(path, std::ios::binary); + if (!file) { + return false; + } + file.seekg(0, std::ios::end); + const std::streamoff size = file.tellg(); + if (size < 0) { + return false; + } + file.seekg(0, std::ios::beg); + content.resize(static_cast(size)); + if (size > 0) { + file.read(content.data(), static_cast(size)); + if (!file) { + return false; + } + } + return true; +} + +std::string mime_type(const std::filesystem::path& file) +{ + std::string extension = file.extension().string(); + for (char& c : extension) { + c = static_cast(std::tolower(static_cast(c))); + } + if (extension == ".html") { + return "text/html; charset=utf-8"; + } + if (extension == ".js" || extension == ".mjs") { + return "text/javascript; charset=utf-8"; + } + if (extension == ".css") { + return "text/css; charset=utf-8"; + } + if (extension == ".json" || extension == ".map") { + return "application/json; charset=utf-8"; + } + if (extension == ".svg") { + return "image/svg+xml; charset=utf-8"; + } + if (extension == ".png") { + return "image/png"; + } + if (extension == ".jpg" || extension == ".jpeg") { + return "image/jpeg"; + } + if (extension == ".gif") { + return "image/gif"; + } + if (extension == ".webp") { + return "image/webp"; + } + if (extension == ".ico") { + return "image/x-icon"; + } + if (extension == ".wasm") { + return "application/wasm"; + } + if (extension == ".txt") { + return "text/plain; charset=utf-8"; + } + if (extension == ".woff") { + return "font/woff"; + } + if (extension == ".woff2") { + return "font/woff2"; + } + return "application/octet-stream"; +} + +ix::HttpResponsePtr make_response(int status, + std::string description, + std::string body, + std::string content_type, + std::string allow = {}) +{ + ix::WebSocketHttpHeaders headers; + headers["Content-Type"] = std::move(content_type); + headers["Cache-Control"] = "no-store"; + headers["X-Content-Type-Options"] = "nosniff"; + headers["Referrer-Policy"] = "no-referrer"; + if (!allow.empty()) { + headers["Allow"] = std::move(allow); + } + return std::make_shared( + status, std::move(description), ix::HttpErrorCode::Ok, headers, std::move(body)); +} + +} // namespace + +struct UiServer::Impl +{ + explicit Impl(UiServerOptions opts) : options(std::move(opts)) {} + + void log(const std::string& line) const + { + if (options.log) { + options.log(line); + } + } + + ix::HttpResponsePtr handle_request(const ix::HttpRequest& request) const + { + std::string target = request.uri; + if (const std::size_t query = target.find('?'); query != std::string::npos) { + target.resize(query); // « /?core=... » : ignorer la requête pour trouver le fichier + } + + const bool is_get = request.method == "GET"; + const bool is_head = request.method == "HEAD"; + if (!is_get && !is_head) { + log(request.method + " " + target + " 405"); + return make_response( + 405, "Method Not Allowed", "405 - méthode non autorisée\n", "text/plain; charset=utf-8", "GET, HEAD"); + } + + const std::optional file = resolve_file(root_canonical, target); + std::string content; + if (!file || !read_file(*file, content)) { + log(request.method + " " + target + " 404"); + return make_response(404, "Not Found", "404 - fichier introuvable\n", "text/plain; charset=utf-8"); + } + + log(request.method + " " + target + " 200"); + std::string body = is_head ? std::string() : std::move(content); + return make_response(200, "OK", std::move(body), mime_type(*file)); + } + + UiServerOptions options; + std::filesystem::path root_canonical; + std::optional server; + bool net_initialized = false; +}; + +UiServer::UiServer(UiServerOptions options) : impl_(std::make_unique(std::move(options))) {} + +UiServer::~UiServer() +{ + stop(); +} + +std::string UiServer::start() +{ + if (impl_->server) { + return {}; + } + + std::error_code ec; + impl_->root_canonical = std::filesystem::canonical(impl_->options.root, ec); + if (ec || impl_->root_canonical.empty() || !std::filesystem::is_directory(impl_->root_canonical, ec)) { + impl_->root_canonical.clear(); + return "studio/ui/dist introuvable : lancer npm run build dans studio/ui (racine : " + + impl_->options.root.string() + ")"; + } + + if (!impl_->net_initialized) { + if (!ix::initNetSystem()) { + return "Initialisation du réseau impossible."; + } + impl_->net_initialized = true; + } + +#ifdef _WIN32 + if (std::string error = probe_port_exclusive(impl_->options.host, static_cast(impl_->options.port)); + !error.empty()) { + return error; + } +#endif + + auto& server = impl_->server.emplace(static_cast(impl_->options.port), impl_->options.host); + const Impl* self = impl_.get(); + server.setOnConnectionCallback( + [self](ix::HttpRequestPtr request, std::shared_ptr) { + return self->handle_request(*request); + }); + + const auto [ok, error] = server.listen(); + if (!ok) { + impl_->server.reset(); + return error; + } + server.start(); + impl_->log("Interface en écoute sur http://" + impl_->options.host + ":" + + std::to_string(impl_->options.port) + " (racine " + impl_->root_canonical.string() + ")"); + return {}; +} + +void UiServer::stop() +{ + if (impl_->server) { + impl_->server->stop(); + impl_->server.reset(); + impl_->log("Interface arrêtée"); + } + if (impl_->net_initialized) { + ix::uninitNetSystem(); + impl_->net_initialized = false; + } +} + +bool UiServer::running() const noexcept +{ + return impl_->server.has_value(); +} + +const UiServerOptions& UiServer::options() const noexcept +{ + return impl_->options; +} + +} // namespace sequa::studio::host diff --git a/studio/host/web/src/urls.cpp b/studio/host/web/src/urls.cpp new file mode 100644 index 0000000..14edcb9 --- /dev/null +++ b/studio/host/web/src/urls.cpp @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MPL-2.0 +// +// Encodage pourcentage RFC 3986 et construction des URL du Studio. +#include "sequa/studio/host/urls.hpp" + +#include +#include + +namespace sequa::studio::host { + +std::string percent_encode(std::string_view value) +{ + static constexpr char hex[] = "0123456789ABCDEF"; + std::string output; + output.reserve(value.size()); + for (const char raw : value) { + const unsigned char c = static_cast(raw); + if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || + c == '.' || c == '_' || c == '~') { + output.push_back(static_cast(c)); + } else { + output.push_back('%'); + output.push_back(hex[c >> 4]); + output.push_back(hex[c & 0x0F]); + } + } + return output; +} + +std::string build_ui_url(std::string_view ui_base_url, std::string_view core_ws_url) +{ + std::string url(ui_base_url); + url += "/?core="; + url += percent_encode(core_ws_url); + return url; +} + +std::string build_core_ws_url(std::string_view host, std::uint16_t port, std::string_view token) +{ + std::string url = "ws://"; + url += host; + url += ':'; + url += std::to_string(port); + url += "/?token="; + url += token; + return url; +} + +} // namespace sequa::studio::host diff --git a/studio/host/web/tests/main.cpp b/studio/host/web/tests/main.cpp new file mode 100644 index 0000000..ecb715c --- /dev/null +++ b/studio/host/web/tests/main.cpp @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: MPL-2.0 +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include diff --git a/studio/host/web/tests/test_browser.cpp b/studio/host/web/tests/test_browser.cpp new file mode 100644 index 0000000..7789840 --- /dev/null +++ b/studio/host/web/tests/test_browser.cpp @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// +// Tests de la validation des URL avant ouverture du navigateur. Aucun navigateur n'est +// réellement ouvert. +#include "sequa/studio/host/browser.hpp" + +#include + +using namespace sequa::studio::host; + +TEST_CASE("Web : is_openable_url n'accepte que http et https") +{ + CHECK(is_openable_url("http://127.0.0.1:9180/?core=x")); + CHECK(is_openable_url("https://example.com/chemin?x=1#f")); + CHECK(is_openable_url("HTTP://EXAMPLE.COM/")); + CHECK(is_openable_url("HtTpS://example.com/")); + + CHECK_FALSE(is_openable_url("")); + CHECK_FALSE(is_openable_url("file:///C:/Windows/win.ini")); + CHECK_FALSE(is_openable_url("javascript:alert(1)")); + CHECK_FALSE(is_openable_url("ftp://example.com/")); + CHECK_FALSE(is_openable_url("http:/pas-de-double-slash")); + CHECK_FALSE(is_openable_url("http://x\ncommande")); + CHECK_FALSE(is_openable_url("http://x\ttab")); + CHECK_FALSE(is_openable_url("http://\x01")); +} diff --git a/studio/host/web/tests/test_ui_server.cpp b/studio/host/web/tests/test_ui_server.cpp new file mode 100644 index 0000000..49b983c --- /dev/null +++ b/studio/host/web/tests/test_ui_server.cpp @@ -0,0 +1,302 @@ +// 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 +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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(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(server_); } + std::uint16_t port() const { return port_; } + const fs::path& root() const { return root_; } + const fs::path& base() const { return base_; } + + std::vector logs() const + { + const std::lock_guard 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", "SEQUA\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 lock(mutex_); + logs_.emplace_back(line); + }; + for (int candidate = 19400; candidate <= 19499; ++candidate) { + options.port = static_cast(candidate); + server_ = std::make_unique(options); + if (server_->start().empty()) { + port_ = static_cast(candidate); + return; + } + server_.reset(); + } + } + + fs::path base_; + fs::path root_; + std::uint16_t port_ = 0; + mutable std::mutex mutex_; + std::vector logs_; + std::unique_ptr 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 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 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()); +} diff --git a/studio/host/web/tests/test_urls.cpp b/studio/host/web/tests/test_urls.cpp new file mode 100644 index 0000000..38639d1 --- /dev/null +++ b/studio/host/web/tests/test_urls.cpp @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MPL-2.0 +// +// Tests purs des URL du Studio et de l'encodage pourcentage. +#include "sequa/studio/host/urls.hpp" + +#include + +#include + +using namespace sequa::studio::host; + +TEST_CASE("Web : build_core_ws_url") +{ + CHECK(build_core_ws_url("127.0.0.1", 9100, "ab12cd") == "ws://127.0.0.1:9100/?token=ab12cd"); + CHECK(build_core_ws_url("localhost", 19400, "") == "ws://localhost:19400/?token="); +} + +TEST_CASE("Web : build_ui_url encode l'URL du Core") +{ + const std::string url = build_ui_url("http://127.0.0.1:9180", "ws://127.0.0.1:9100/?token=ab12"); + CHECK(url == "http://127.0.0.1:9180/?core=ws%3A%2F%2F127.0.0.1%3A9100%2F%3Ftoken%3Dab12"); +} + +TEST_CASE("Web : percent_encode respecte les non-reserves RFC 3986") +{ + CHECK(percent_encode("AZaz09-._~") == "AZaz09-._~"); + CHECK(percent_encode("a b/c?d=e&f") == "a%20b%2Fc%3Fd%3De%26f"); + CHECK(percent_encode("") == ""); + // Octets UTF-8 de « é » (C3 A9). + CHECK(percent_encode("\xC3\xA9") == "%C3%A9"); + // Les octets bruts ne doivent pas passer. + CHECK(percent_encode(std::string("\x00\x7F\x80", 3)) == "%00%7F%80"); +}