From 4716507c59c0a70c64b240eb8d48026b943ca003 Mon Sep 17 00:00:00 2001 From: Antonio Leblanc Date: Wed, 21 Oct 2026 19:29:23 -0300 Subject: [PATCH] listenHTTP: fixes after #199 Bind inside Docker with listenHTTP[0.0.0.0:8000] in the docs, treat an empty host as localhost, write the real hostname to httpConnectInfo.txt on 0.0.0.0, close the socket when bind fails, restore remoteCommand and cout on exceptions, and refuse commands from another site or host name. Thanks @HugoFara for the review. --- CHANGELOG.md | 11 ++- README.md | 8 +- .../getting_started/execution_modes.rst | 2 + docs/source/getting_started/quickstart.rst | 5 +- docs/source/reference/commands.rst | 2 +- src/Command.cpp | 24 ++++-- src/Command.h | 7 ++ src/EventCommand.cpp | 4 +- src/HttpCommandServer.hpp | 74 +++++++++++++++++-- 9 files changed, 109 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f609cd78..15093ac5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,10 +21,12 @@ Merged since v2.5.0, not yet released. - Free-threaded (`cp314t`) wheels are no longer published, because the core is not yet thread-safe; CPython 3.14 is declared supported. ([#155]) - `listenHTTP[]` binds to `localhost` by default and to the host given in - `listenHTTP[host:port]`; use `listenHTTP[0.0.0.0:8000]` for the network, - which prints a warning. - `systemExec` is refused over HTTP, and the file server stays inside the run - directory and `FOREFIREHOME/tools/htdocs`. + `listenHTTP[host:port]`; use `listenHTTP[0.0.0.0:8000]` for the network + (and inside Docker), which prints a warning. + `systemExec` is refused over HTTP, the static file server only serves the run + directory and `FOREFIREHOME/tools/htdocs`, and commands from another site or + host name are refused. Other commands can still read and write files, so the + server is meant for trusted local use. ([#199]) ## [v2.5.0] — 2026-08-11 @@ -139,3 +141,4 @@ repository stays easy to return to. [#152]: https://github.com/forefireAPI/forefire/pull/152 [#154]: https://github.com/forefireAPI/forefire/pull/154 [#155]: https://github.com/forefireAPI/forefire/pull/155 +[#199]: https://github.com/forefireAPI/forefire/pull/199 diff --git a/README.md b/README.md index 66281eb3..07d47ab9 100644 --- a/README.md +++ b/README.md @@ -113,14 +113,16 @@ The easiest way to get started is often using Docker and the interactive console ``` -5. Launch the HTTP server from the console: +5. Launch the HTTP server from the console. Inside the container, bind to all + interfaces so the published port reaches it (`listenHTTP[]` alone only + listens on the container's own localhost): ```bash - listenHTTP[] + listenHTTP[0.0.0.0:8000] ``` the output should be : ```bash - >> ForeFire HTTP command server listening at http://localhost:8000 + >> ForeFire HTTP command server listening at http://0.0.0.0:8000 ``` This server provides a graphical user interface that you can access on your browser at http://localhost:8000/ diff --git a/docs/source/getting_started/execution_modes.rst b/docs/source/getting_started/execution_modes.rst index 899e858a..1763a868 100644 --- a/docs/source/getting_started/execution_modes.rst +++ b/docs/source/getting_started/execution_modes.rst @@ -135,6 +135,8 @@ This method, which you may have already used in the :doc:`quickstart` guide, use forefire -l + Both bind to ``localhost``. Inside a Docker container, use ``listenHTTP[0.0.0.0:8000]`` instead so the published port reaches the server. + 4. **Use the Web Interface:** - Open your browser to ``http://localhost:8000/`` (or the specified port). diff --git a/docs/source/getting_started/quickstart.rst b/docs/source/getting_started/quickstart.rst index e4b5dfcd..8944229c 100644 --- a/docs/source/getting_started/quickstart.rst +++ b/docs/source/getting_started/quickstart.rst @@ -103,9 +103,10 @@ Steps .. code-block:: none - forefire> listenHTTP[] + forefire> listenHTTP[0.0.0.0:8000] - You should see the output : `>> ForeFire HTTP command server listening at http://localhost:8000` + You should see the output : `>> ForeFire HTTP command server listening at http://0.0.0.0:8000`, followed by a warning that the server is reachable from the network. + Inside a container this is needed: ``listenHTTP[]`` alone only listens on the container's own localhost, which the published port cannot reach. 7. **Access the Web Console:** diff --git a/docs/source/reference/commands.rst b/docs/source/reference/commands.rst index d52eec46..1d416ff2 100644 --- a/docs/source/reference/commands.rst +++ b/docs/source/reference/commands.rst @@ -587,7 +587,7 @@ Launches an HTTP server (on the machine running ForeFire) that listens for simul **Arguments:** -* Positional 1: Hostname or IP address to bind the server to, followed by a colon, followed by the port number (e.g., `127.0.0.1:8000`, `0.0.0.0:8080`). +* Positional 1: Hostname or IP address to bind the server to, followed by a colon, followed by the port number (e.g., `127.0.0.1:8000`, `0.0.0.0:8080`). Without a host (``listenHTTP[]`` or ``listenHTTP[:8000]``) the server binds to ``localhost``. ``0.0.0.0`` opens it to the network, as needed inside a Docker container. **Example:** diff --git a/src/Command.cpp b/src/Command.cpp index 2e02f540..04d54fb2 100644 --- a/src/Command.cpp +++ b/src/Command.cpp @@ -3376,16 +3376,16 @@ namespace libforefire std::string Command::executeCommandAndCaptureOutput(const std::string &cmd) { // 'cmd' is assumed to be the command text after the "ff:" prefix. - std::streambuf *oldbuf = std::cout.rdbuf(); std::ostringstream captured; - std::cout.rdbuf(captured.rdbuf()); + // Restore cout even if the command throws. + struct CoutRedirect { + std::streambuf *old; + explicit CoutRedirect(std::streambuf *buf) : old(std::cout.rdbuf(buf)) {} + ~CoutRedirect() { std::cout.rdbuf(old); } + } redirect(captured.rdbuf()); + RemoteCommandScope remote(true); std::string fireCommand = cmd; - // Execute the command. (Assuming ExecuteCommand is defined elsewhere.) - remoteCommand = true; ExecuteCommand(fireCommand); - remoteCommand = false; - - std::cout.rdbuf(oldbuf); return captured.str(); } @@ -3421,6 +3421,9 @@ namespace libforefire std::string address = arg.empty() ? defaultHostname + ":" + std::to_string(port) : arg; if (address.compare(0, 5, "host=") == 0) address = address.substr(5); + // listenHTTP[:8000] means localhost too. + if (!address.empty() && address[0] == ':') + address = defaultHostname + address; // Allocate a new HTTP server if none exists. if (currentSession.server == 0) @@ -3437,10 +3440,15 @@ namespace libforefire } else { + // Other nodes need the machine name, not the wildcard address. + std::string connectAddress = address; + char hostname[256]; + if (address.compare(0, 8, "0.0.0.0:") == 0 && gethostname(hostname, sizeof(hostname)) == 0) + connectAddress = std::string(hostname) + address.substr(7); std::ofstream infoFile("httpConnectInfo.txt"); if (infoFile) { - infoFile << address << std::endl; + infoFile << connectAddress << std::endl; infoFile.close(); } else diff --git a/src/Command.h b/src/Command.h index 7387fc55..455cfcd2 100644 --- a/src/Command.h +++ b/src/Command.h @@ -235,6 +235,13 @@ class Command { // True while running a command received over HTTP (or scheduled by one) static thread_local bool remoteCommand; + // Sets remoteCommand for a scope and restores the previous value, even on exceptions + struct RemoteCommandScope { + bool previous; + explicit RemoteCommandScope(bool remote) : previous(remoteCommand) { remoteCommand = remote; } + ~RemoteCommandScope() { remoteCommand = previous; } + }; + // Reference time static double refTime; diff --git a/src/EventCommand.cpp b/src/EventCommand.cpp index 099a4d87..f9c63b53 100644 --- a/src/EventCommand.cpp +++ b/src/EventCommand.cpp @@ -30,10 +30,8 @@ EventCommand::~EventCommand() {}; void EventCommand::update(){ - bool caller = Command::remoteCommand; - Command::remoteCommand = remote; + Command::RemoteCommandScope scope(remote); Command::ExecuteCommand(schedueledCommand); - Command::remoteCommand = caller; } void EventCommand::timeAdvance(){ setUpdateTime(numeric_limits::infinity()); diff --git a/src/HttpCommandServer.hpp b/src/HttpCommandServer.hpp index 134d0821..72b05d28 100644 --- a/src/HttpCommandServer.hpp +++ b/src/HttpCommandServer.hpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include @@ -78,12 +79,17 @@ namespace http_command { addr.sin_port = htons(port); if (::bind(server_fd, reinterpret_cast(&addr), sizeof(addr)) < 0) { perror("bind"); + close(server_fd); + server_fd = -1; return false; } if (::listen(server_fd, 10) < 0) { perror("listen"); + close(server_fd); + server_fd = -1; return false; } + loopbackOnly = (ntohl(bindAddr.s_addr) >> 24) == 127; running = true; server_thread = std::thread(&HttpCommandServer::acceptLoop, this); std::cout @@ -92,7 +98,7 @@ namespace http_command { << "\033[0m" << std::endl; - if ((ntohl(bindAddr.s_addr) >> 24) != 127) { + if (!loopbackOnly) { std::cout << "\033[31m" // start red << "WARNING: development server, reachable from the network. Anyone who can reach this port can run ForeFire commands. Use listenHTTP[localhost:" << port << "] unless you need remote access." @@ -159,6 +165,13 @@ namespace http_command { std::string method, uri, version; requestStream >> method >> uri >> version; + if (!allowedHost(request)) { + std::string refused = buildSimpleResponse("403 Forbidden", "Forbidden request"); + ssize_t refused_written = write(client_fd, refused.data(), refused.size()); + (void)refused_written; + return; + } + std::string complete_response; // Handle POST requests: look for an ff: command in the body. if (method == "POST") { @@ -168,9 +181,7 @@ namespace http_command { body = request.substr(pos + 4); } if (body.size() > 3 && (body.substr(0, 3) == "ff:" || body.substr(0, 3) == "FF:")) { - std::string fireCommand = body.substr(3); - std::string result = callback(fireCommand); - complete_response = buildResponse("200 OK", "text/plain; charset=UTF-8", result); + complete_response = runCommand(request, body.substr(3)); } else { complete_response = serveFileOrDirectory(uri); } @@ -178,9 +189,7 @@ namespace http_command { // Handle GET requests. else { if (uri.size() > 4 && (uri.substr(0, 4) == "/ff:" || uri.substr(0, 4) == "/FF:")) { - std::string fireCommand = uri.substr(4); - std::string result = callback(fireCommand); - complete_response = buildResponse("200 OK", "text/plain; charset=UTF-8", result); + complete_response = runCommand(request, uri.substr(4)); } else { complete_response = serveFileOrDirectory(uri); } @@ -189,6 +198,56 @@ namespace http_command { (void)bytes_written; } + // Value of an HTTP header (name in lowercase), empty if absent. + static std::string headerValue(const std::string &request, const std::string &name) { + std::istringstream lines(request); + std::string line; + std::getline(lines, line); // request line + while (std::getline(lines, line) && line != "\r" && !line.empty()) { + size_t colon = line.find(':'); + if (colon == std::string::npos) + continue; + std::string key = line.substr(0, colon); + for (char &c : key) + c = static_cast(std::tolower(static_cast(c))); + if (key != name) + continue; + size_t start = line.find_first_not_of(" \t", colon + 1); + size_t end = line.find_last_not_of(" \t\r"); + return (start == std::string::npos) ? "" : line.substr(start, end - start + 1); + } + return ""; + } + + static bool isLoopbackHost(std::string host) { + if (!host.empty() && host[0] == '[') + return host.compare(0, 5, "[::1]") == 0; + host = host.substr(0, host.find(':')); + return host == "localhost" || host.compare(0, 4, "127.") == 0; + } + + // DNS rebinding: a local server only answers to local names. + bool allowedHost(const std::string &request) { + std::string host = headerValue(request, "host"); + return !loopbackOnly || host.empty() || isLoopbackHost(host); + } + + // Commands only from the server's own pages or non-browser clients, + // not from another site (e.g. ). + static bool sameOriginCommand(const std::string &request) { + std::string site = headerValue(request, "sec-fetch-site"); + if (!site.empty() && site != "same-origin" && site != "none") + return false; + std::string origin = headerValue(request, "origin"); + return origin.empty() || origin == "http://" + headerValue(request, "host"); + } + + std::string runCommand(const std::string &request, const std::string &fireCommand) { + if (!sameOriginCommand(request)) + return buildSimpleResponse("403 Forbidden", "Cross-site command refused"); + return buildResponse("200 OK", "text/plain; charset=UTF-8", callback(fireCommand)); + } + // Check whether a file exists. bool fileExists(const std::string &path) { std::ifstream file(path, std::ios::binary); @@ -344,6 +403,7 @@ namespace http_command { } int server_fd; + bool loopbackOnly = true; std::atomic running; std::thread server_thread; CommandCallback callback;