feat(studio/host): brique web, service de l'interface et ouverture du navigateur

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.
This commit is contained in:
Ange-Marie MAURIN - A2M Systèmes committed 2026-11-06 22:41:21 +01:00
1 parent e703c217f7
commit 99b0a0f008
12 files changed
+1125

No files matched your search

+32
View File
@@ -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()
+76
View File
@@ -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=<url WebSocket encodée>`) 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<void(std::string_view)> 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.
@@ -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 <string_view>
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
@@ -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 <cstdint>
#include <filesystem>
#include <functional>
#include <memory>
#include <string>
#include <string_view>
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<void(std::string_view)> 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> impl_;
};
} // namespace sequa::studio::host
@@ -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 <cstdint>
#include <string>
#include <string_view>
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=<token>".
std::string build_core_ws_url(std::string_view host, std::uint16_t port, std::string_view token);
} // namespace sequa::studio::host
+96
View File
@@ -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 <cctype>
#include <cstdint>
#include <string>
#include <string_view>
#ifdef _WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#include <shellapi.h>
#else
#include <cerrno>
#include <spawn.h>
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<unsigned char>(value[i])) !=
std::tolower(static_cast<unsigned char>(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<unsigned char>(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<int>(value.size()), nullptr, 0);
if (wide_length <= 0) {
return false;
}
std::wstring wide(static_cast<std::size_t>(wide_length), L'\0');
if (::MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast<int>(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<std::intptr_t>(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<char*>(program), const_cast<char*>(value.c_str()), nullptr};
const int rc = ::posix_spawnp(&pid, program, nullptr, nullptr, argv, environ);
return rc == 0;
#endif
}
} // namespace sequa::studio::host
+420
View File
@@ -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 <ixwebsocket/IXHttp.h>
#include <ixwebsocket/IXHttpServer.h>
#include <ixwebsocket/IXNetSystem.h>
#include <cctype>
#include <cstddef>
#include <cstdint>
#include <fstream>
#include <ios>
#include <optional>
#include <string>
#include <string_view>
#include <system_error>
#include <utility>
#ifdef _WIN32
#include <winsock2.h>
#include <ws2tcpip.h>
#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<const char*>(&exclusive),
sizeof(exclusive));
sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_port = ::htons(static_cast<u_short>(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<const sockaddr*>(&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<char>((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<std::filesystem::path> 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<std::filesystem::path> resolve_file(const std::filesystem::path& root_canonical,
std::string_view raw_path)
{
const std::optional<std::filesystem::path> 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<std::size_t>(size));
if (size > 0) {
file.read(content.data(), static_cast<std::streamsize>(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<char>(std::tolower(static_cast<unsigned char>(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<ix::HttpResponse>(
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<std::filesystem::path> 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<ix::HttpServer> server;
bool net_initialized = false;
};
UiServer::UiServer(UiServerOptions options) : impl_(std::make_unique<Impl>(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<int>(impl_->options.port));
!error.empty()) {
return error;
}
#endif
auto& server = impl_->server.emplace(static_cast<int>(impl_->options.port), impl_->options.host);
const Impl* self = impl_.get();
server.setOnConnectionCallback(
[self](ix::HttpRequestPtr request, std::shared_ptr<ix::ConnectionState>) {
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
+49
View File
@@ -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 <cstdint>
#include <string>
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<unsigned char>(raw);
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' ||
c == '.' || c == '_' || c == '~') {
output.push_back(static_cast<char>(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
+3
View File
@@ -0,0 +1,3 @@
// SPDX-License-Identifier: MPL-2.0
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include <doctest/doctest.h>
+26
View File
@@ -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 <doctest/doctest.h>
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"));
}
+302
View File
@@ -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 <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());
}
+33
View File
@@ -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 <doctest/doctest.h>
#include <string>
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");
}