Sfoglia il codice sorgente

feat: an optional password on a shared form

fszontagh 1 mese fa
parent
commit
881008d1b1

+ 68 - 0
src/webserver/api/webhook_controller.cpp

@@ -6,6 +6,7 @@
 #include "common/string_utils.hpp"
 #include "proto/runner.grpc.pb.h"
 #include <grpcpp/grpcpp.h>
+#include <openssl/crypto.h>
 #include <cctype>
 #include <optional>
 #include <sstream>
@@ -41,6 +42,7 @@ WebhookController::WebhookController(storage::StorageClient& storage,
                                      WebSocketServer& ws_server,
                                      nodes::NodeStore& node_store,
                                      WorkflowScheduler& scheduler,
+                                     auth::JwtUtils& jwt_utils,
                                      DispatchConfig dispatch_config)
     : storage_(storage)
     , registry_(registry)
@@ -48,6 +50,7 @@ WebhookController::WebhookController(storage::StorageClient& storage,
     , ws_server_(ws_server)
     , node_store_(node_store)
     , scheduler_(scheduler)
+    , jwt_utils_(jwt_utils)
     , dispatch_queue_capacity_(dispatch_config.queue_capacity) {
     dispatch_workers_.reserve(dispatch_config.threads);
     for (std::size_t i = 0; i < dispatch_config.threads; ++i) {
@@ -218,6 +221,34 @@ void WebhookController::handleWebhook(const httplib::Request& req, httplib::Resp
             sendError(res, "Path not found", 404);
             return;
         }
+
+        const std::string form_password = form_config.value("password", "");
+        if (!form_password.empty() && !formCookieValid(req, workflow_id)) {
+            const std::string action = "/webhook/" + workflow_id + path;
+            std::string attempt;
+            if (req.method == "POST" && req.has_param("__form_password")) {
+                attempt = req.get_param_value("__form_password");
+            }
+            if (!attempt.empty() && attempt.size() == form_password.size() &&
+                CRYPTO_memcmp(attempt.data(), form_password.data(), attempt.size()) == 0) {
+                const int64_t expires_at = TimeUtils::nowMs() + 3600 * 1000;
+                res.set_header("Set-Cookie",
+                               "sb_form_" + workflow_id + "=" + formCookieToken(workflow_id, expires_at) +
+                               "; Path=/webhook/" + workflow_id + "; HttpOnly; SameSite=Lax; Max-Age=3600");
+                res.status = 200;
+                res.set_content(form_renderer::renderForm(form_config, action, ""),
+                                "text/html; charset=utf-8");
+                return;
+            }
+            res.status = 200;
+            // Deliberately generic: a message naming the form would confirm that
+            // one exists at this URL to somebody guessing.
+            res.set_content(form_renderer::renderPasswordPrompt(
+                                action, attempt.empty() ? "" : "That did not work."),
+                            "text/html; charset=utf-8");
+            return;
+        }
+
         if (req.method == "GET") {
             handleFormGet(req, res, form_node.value(), workflow_id, path);
             return;
@@ -644,6 +675,43 @@ void WebhookController::handleFormGet(const httplib::Request& req, httplib::Resp
     res.set_content(form_renderer::renderForm(config, action, ""), "text/html; charset=utf-8");
 }
 
+std::string WebhookController::formCookieToken(const std::string& workflow_id, int64_t expires_at) {
+    // A signed token rather than the password: the password never reaches the
+    // browser, and a stolen cookie opens one form until it expires.
+    const std::string payload = workflow_id + ":" + std::to_string(expires_at);
+    return payload + ":" + jwt_utils_.signDetached(payload);
+}
+
+bool WebhookController::formCookieValid(const httplib::Request& req, const std::string& workflow_id) {
+    const std::string cookie_header = req.get_header_value("Cookie");
+    const std::string key = "sb_form_" + workflow_id + "=";
+    const auto at = cookie_header.find(key);
+    if (at == std::string::npos) return false;
+
+    std::string value = cookie_header.substr(at + key.size());
+    const auto end = value.find(';');
+    if (end != std::string::npos) value = value.substr(0, end);
+
+    const auto first = value.find(':');
+    const auto second = value.rfind(':');
+    if (first == std::string::npos || second == first) return false;
+
+    const std::string payload = value.substr(0, second);
+    const std::string signature = value.substr(second + 1);
+    if (!jwt_utils_.verifyDetached(payload, signature)) return false;
+
+    const std::string id = payload.substr(0, first);
+    if (id != workflow_id) return false;
+
+    int64_t expires_at = 0;
+    try {
+        expires_at = std::stoll(payload.substr(first + 1));
+    } catch (const std::exception&) {
+        return false;
+    }
+    return TimeUtils::nowMs() < expires_at;
+}
+
 bool WebhookController::buildFormTriggerData(const httplib::Request& req,
                                              const nlohmann::json& config,
                                              nlohmann::json& out, std::string& error) {

+ 15 - 0
src/webserver/api/webhook_controller.hpp

@@ -13,6 +13,7 @@
 #include "../websocket_server.hpp"
 #include "../nodes/node_store.hpp"
 #include "../scheduler/workflow_scheduler.hpp"
+#include "../auth/jwt_utils.hpp"
 
 namespace grpc {
 class ClientContext;
@@ -40,6 +41,7 @@ public:
                      WebSocketServer& ws_server,
                      nodes::NodeStore& node_store,
                      WorkflowScheduler& scheduler,
+                     auth::JwtUtils& jwt_utils,
                      DispatchConfig dispatch_config = {});
     ~WebhookController();
 
@@ -66,6 +68,15 @@ private:
     bool buildFormTriggerData(const httplib::Request& req, const nlohmann::json& config,
                               nlohmann::json& out, std::string& error);
 
+public:
+    // A signed token rather than the password itself: the password never
+    // reaches the browser, and a stolen cookie opens one form until it
+    // expires rather than the account behind it.
+    std::string formCookieToken(const std::string& workflow_id, int64_t expires_at);
+    bool formCookieValid(const httplib::Request& req, const std::string& workflow_id);
+
+private:
+
     void sendJson(httplib::Response& res, const nlohmann::json& data, int status = 200);
     void sendError(httplib::Response& res, const std::string& message, int status);
 
@@ -91,6 +102,10 @@ private:
     // Held so a webhook run counts towards a schedule's overlap policy, the
     // same as any other way of starting the workflow.
     WorkflowScheduler& scheduler_;
+    // Signs and verifies the form password cookie - the same secret and HMAC
+    // implementation the login session tokens use, exposed through
+    // signDetached/verifyDetached rather than duplicated here.
+    auth::JwtUtils& jwt_utils_;
 
     // A queued but not-yet-started dispatch. Kept alongside its workflow id
     // so a discard at shutdown can be logged by name, not just counted.

+ 17 - 1
src/webserver/auth/jwt_utils.cpp

@@ -3,6 +3,7 @@
 #include "common/string_utils.hpp"
 #include <openssl/hmac.h>
 #include <openssl/evp.h>
+#include <openssl/crypto.h>
 #include <cstring>
 
 namespace smartbotic::webserver::auth {
@@ -175,7 +176,22 @@ std::string JwtUtils::sign(const std::string& data) {
 
 bool JwtUtils::verifySignature(const std::string& data, const std::string& signature) {
     std::string expected = sign(data);
-    return expected == signature;
+    // A length mismatch is itself decided before any byte-by-byte compare -
+    // CRYPTO_memcmp requires equal-length buffers and reading past either one
+    // would be undefined, so this is not optional. The length check leaks
+    // only the length, never which byte first differed.
+    if (expected.size() != signature.size()) {
+        return false;
+    }
+    return CRYPTO_memcmp(expected.data(), signature.data(), expected.size()) == 0;
+}
+
+std::string JwtUtils::signDetached(const std::string& data) {
+    return sign(data);
+}
+
+bool JwtUtils::verifyDetached(const std::string& data, const std::string& signature) {
+    return verifySignature(data, signature);
 }
 
 } // namespace smartbotic::webserver::auth

+ 7 - 0
src/webserver/auth/jwt_utils.hpp

@@ -57,6 +57,13 @@ public:
     // Extract token from Authorization header
     static std::optional<std::string> extractBearerToken(const std::string& auth_header);
 
+    // Signs and verifies arbitrary caller-chosen data with this instance's
+    // HMAC secret, outside the JWT envelope - so a second, independent piece
+    // of the codebase (the form password cookie) can reuse the one signing
+    // implementation and one secret instead of copying the HMAC call.
+    std::string signDetached(const std::string& data);
+    bool verifyDetached(const std::string& data, const std::string& signature);
+
 private:
     std::string generateToken(const TokenPayload& payload);
     std::string base64UrlEncode(const std::string& data);

+ 1 - 1
src/webserver/webserver_service.cpp

@@ -416,7 +416,7 @@ void WebServerService::setupRoutes() {
     form_dispatch_config.queue_capacity = config_.form_dispatch_queue_capacity;
     webhook_ctrl_ = std::make_unique<api::WebhookController>(
         *storage_, *runner_registry_, *load_balancer_, *ws_server_, *node_store_, *scheduler_,
-        form_dispatch_config);
+        *jwt_, form_dispatch_config);
     webhook_ctrl_->registerRoutes(server);
 
     database_ctrl_ = std::make_unique<api::DatabaseController>(*storage_, *auth_middleware_);

+ 16 - 0
tests/nodes/form-password.json

@@ -0,0 +1,16 @@
+{
+  "name": "verify-form-password",
+  "nodes": [
+    {"id": "n1", "name": "Form", "type": "form-trigger", "position": {"x": 0, "y": 0},
+     "config": {"title": "Private form", "password": "hunter2",
+                "fields": [{"name": "note", "label": "Note", "type": "text"}]}}
+  ],
+  "connections": [],
+  "http": {
+    "method": "GET",
+    "path": "/webhook/{workflowId}",
+    "expectStatus": 200,
+    "expectBodyContains": ["This form is protected", "__form_password"],
+    "expectBodyExcludes": ["hunter2", "name=\"note\""]
+  }
+}