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;