Selaa lähdekoodia

feat(auth): CAPTCHA verification on require_human_token operations

Add captcha.hpp/captcha.cpp (verifyCaptcha): fail-closed implementation
that posts to the configured captcha_verify_url and checks the `success`
field in the JSON response. Disabled when captchaProvider/token/url are
empty. Wire into requireCapability in server.cpp: when a scope rule has
requireHumanToken=true, extract X-Captcha-Token header and call
verifyCaptcha; missing or failing token returns 403 captcha_required.
Add test_captcha.cpp with in-process mock HTTP server verifying both
pass and fail paths. Register test_captcha in tests/CMakeLists.txt.
Fszontagh 1 kuukausi sitten
vanhempi
sitoutus
fb86e96005
6 muutettua tiedostoa jossa 70 lisäystä ja 2 poistoa
  1. 1 0
      src/CMakeLists.txt
  2. 24 0
      src/captcha.cpp
  3. 8 0
      src/captcha.hpp
  4. 8 2
      src/server.cpp
  5. 4 0
      tests/CMakeLists.txt
  6. 25 0
      tests/test_captcha.cpp

+ 1 - 0
src/CMakeLists.txt

@@ -12,6 +12,7 @@ add_library(vectorapi_core STATIC
     embeddings.cpp
     embedding_cache.cpp
     rate_limiter.cpp
+    captcha.cpp
     auth.cpp
     server.cpp
     handlers/meta.cpp

+ 24 - 0
src/captcha.cpp

@@ -0,0 +1,24 @@
+#include "captcha.hpp"
+#include "settings.hpp"
+#include <httplib.h>
+#include <nlohmann/json.hpp>
+namespace svapi {
+bool verifyCaptcha(const Settings& s, const std::string& token, const std::string& remoteIp) {
+    if (s.captchaProvider.empty() || token.empty() || s.captchaVerifyUrl.empty()) return false;
+    std::string url = s.captchaVerifyUrl;
+    while (!url.empty() && url.back() == '/') url.pop_back();
+    auto schemeEnd = url.find("://");
+    std::size_t from = (schemeEnd == std::string::npos) ? 0 : schemeEnd + 3;
+    auto slash = url.find('/', from);
+    std::string origin = (slash == std::string::npos) ? url : url.substr(0, slash);
+    std::string path   = (slash == std::string::npos) ? "/"  : url.substr(slash);
+    httplib::Client cli(origin);
+    cli.set_connection_timeout(5); cli.set_read_timeout(10);
+    cli.enable_server_certificate_verification(true);
+    httplib::Params params{{"secret", s.captchaSecret}, {"response", token}, {"remoteip", remoteIp}};
+    auto res = cli.Post(path, params);
+    if (!res || res->status != 200) return false;
+    try { return nlohmann::json::parse(res->body).value("success", false); }
+    catch (...) { return false; }
+}
+}

+ 8 - 0
src/captcha.hpp

@@ -0,0 +1,8 @@
+#pragma once
+#include <string>
+namespace svapi {
+struct Settings;
+// Verify a CAPTCHA token. Fail-closed: returns false on disabled provider,
+// empty token/url, network error, or non-success provider response.
+bool verifyCaptcha(const Settings& s, const std::string& token, const std::string& remoteIp);
+}

+ 8 - 2
src/server.cpp

@@ -1,4 +1,5 @@
 #include "server.hpp"
+#include "captcha.hpp"
 #include "errors.hpp"
 #include "json_http.hpp"
 #include "rate_limiter.hpp"
@@ -58,8 +59,13 @@ void requireCapability(ServerDeps& d, const ApiKey& k, const httplib::Request& r
         throw ApiError(ErrCode::Forbidden, "forbidden", "origin not allowed for this key");
     if (!rateLimiter().allow(k.id, sc.rateLimitPerMin))
         throw ApiError(ErrCode::TooManyRequests, "rate_limited", "rate limit exceeded");
-    // Phase 2 (CAPTCHA) inserts the rule->requireHumanToken check here.
-    (void)d;
+    if (rule->requireHumanToken) {
+        std::string token;
+        if (auto it = req.headers.find("X-Captcha-Token"); it != req.headers.end()) token = it->second;
+        auto snap = d.settings.snapshot();
+        if (!verifyCaptcha(*snap, token, req.remote_addr))
+            throw ApiError(ErrCode::Forbidden, "captcha_required", "human verification required");
+    }
 }
 
 void ApiServer::registerRoutes() {

+ 4 - 0
tests/CMakeLists.txt

@@ -49,6 +49,10 @@ add_executable(test_rate_limiter test_rate_limiter.cpp)
 target_link_libraries(test_rate_limiter PRIVATE vectorapi_core GTest::gtest_main)
 gtest_discover_tests(test_rate_limiter)
 
+add_executable(test_captcha test_captcha.cpp)
+target_link_libraries(test_captcha PRIVATE vectorapi_core GTest::gtest_main)
+gtest_discover_tests(test_captcha)
+
 add_executable(test_api_integration test_api_integration.cpp)
 target_link_libraries(test_api_integration PRIVATE vectorapi_core GTest::gtest_main)
 target_compile_definitions(test_api_integration PRIVATE SVAPI_API_DOCS_DIR="${CMAKE_SOURCE_DIR}/api")

+ 25 - 0
tests/test_captcha.cpp

@@ -0,0 +1,25 @@
+#include <gtest/gtest.h>
+#include "captcha.hpp"
+#include "settings.hpp"
+#include <httplib.h>
+#include <thread>
+#include <chrono>
+using namespace svapi;
+TEST(Captcha, VerifiesAgainstProvider) {
+    httplib::Server mock;
+    mock.Post("/verify", [](const httplib::Request& req, httplib::Response& res){
+        bool ok = req.body.find("good") != std::string::npos;
+        res.set_content(ok ? R"({"success":true})" : R"({"success":false})", "application/json");
+    });
+    int port = mock.bind_to_any_port("127.0.0.1");
+    std::thread t([&]{ mock.listen_after_bind(); });
+    while (!mock.is_running()) std::this_thread::sleep_for(std::chrono::milliseconds(5));
+    Settings s; s.captchaProvider = "turnstile";
+    s.captchaVerifyUrl = "http://127.0.0.1:" + std::to_string(port) + "/verify";
+    s.captchaSecret = "sek";
+    EXPECT_TRUE(verifyCaptcha(s, "good-token", "1.2.3.4"));
+    EXPECT_FALSE(verifyCaptcha(s, "bad-token", "1.2.3.4"));
+    EXPECT_FALSE(verifyCaptcha(s, "", "1.2.3.4"));
+    Settings off; EXPECT_FALSE(verifyCaptcha(off, "good", ""));
+    mock.stop(); t.join();
+}