浏览代码

test+docs(auth): CAPTCHA-gated insert

Add ApiFixture.CaptchaGatedInsert integration test: creates a scoped key
with require_human_token:true on a quotes/insert rule, spins up an
in-process mock captcha server, and verifies that POST without
X-Captcha-Token returns 403 and with a good token returns 201. Skips
cleanly when DB is unreachable.

Update api/openapi.json: add X-Captcha-Token header parameter (in:header,
required:false) to POST /documents and POST /vectors; add
captcha_provider/captcha_secret/captcha_verify_url to PUT /settings
request body; add captcha_provider/captcha_secret_set/captcha_verify_url
to GET /settings response schema. JSON validated.

Update api/llms.txt: document require_human_token in scope rules, the
X-Captcha-Token header, 403 captcha_required error, and captcha_*
settings fields.
Fszontagh 1 月之前
父节点
当前提交
5a17baf6a7
共有 3 个文件被更改,包括 109 次插入4 次删除
  1. 6 2
      api/llms.txt
  2. 26 2
      api/openapi.json
  3. 77 0
      tests/test_api_integration.cpp

+ 6 - 2
api/llms.txt

@@ -31,11 +31,15 @@ A non-admin key may carry a `scope` object that restricts what it can do. Scoped
 
 `scope` fields:
 
-- `rules` (required, non-empty array) — Each rule specifies a `collection` name and the permitted `ops` array (`read`, `list`, `search`, `insert`, `update`, `delete`). A request succeeds only if a matching `{collection, op}` rule exists.
+- `rules` (required, non-empty array) — Each rule specifies a `collection` name and the permitted `ops` array (`read`, `list`, `search`, `insert`, `update`, `delete`). A request succeeds only if a matching `{collection, op}` rule exists. A rule may also set `require_human_token: true` (see CAPTCHA below).
 - `origins` (array of strings, default empty) — Allowed `Origin` header values. When non-empty, requests without a matching `Origin` header are rejected with 403 (**fails closed** — no origin header means denied). Use this to pin a key to a specific domain.
 - `rate_limit_per_min` (integer, default 0 = unlimited) — Per-key request rate limit. When the limit is exceeded the server returns **429** with a `Retry-After: 60` header.
 - `expires_at` (integer, default 0 = never) — Unix epoch seconds after which the key is treated as expired; auth returns 401.
 
+### CAPTCHA-gated operations
+
+A scope rule may set `require_human_token: true`. When this flag is active, the client must supply an `X-Captcha-Token` request header containing the CAPTCHA challenge response. The server verifies the token via the configured provider endpoint and returns **403** `captcha_required` if the token is absent, invalid, or if no provider is configured. Configure the provider via the settings API: `captcha_provider` (`"turnstile"` or `"hcaptcha"`), `captcha_secret` (server-side secret), and `captcha_verify_url` (full verify endpoint URL).
+
 Example scope (insert-only, origin-pinned, rate-limited, expiring):
 ```json
 {
@@ -88,7 +92,7 @@ Requires a `kind=vector` collection. Vectors are stored with cosine-similarity i
 All settings endpoints require an admin key.
 
 - `GET  /api/v1/settings` — Return current settings as JSON. `openai_api_key` is masked: the field is absent and `openai_api_key_set` (bool) indicates whether a key is configured.
-- `PUT  /api/v1/settings` `{openai_api_base?, openai_api_key?, default_embedding_model?, cors_origins?, session_ttl_minutes?, webui_enabled?, default_project?, embedding_connect_timeout_sec?, embedding_read_timeout_sec?, embedding_cache_size?, embedding_cache_ttl_sec?, embedding_cache_max_bytes?, embedding_cache_normalize?, embedding_client_pool_size?}` — Merge the supplied fields into the current settings and persist. Omit `openai_api_key` (or pass an empty string) to keep the existing secret. Changes are hot-reloaded immediately; cache settings are applied instantly via `reconfigure()`.
+- `PUT  /api/v1/settings` `{openai_api_base?, openai_api_key?, default_embedding_model?, cors_origins?, session_ttl_minutes?, webui_enabled?, default_project?, embedding_connect_timeout_sec?, embedding_read_timeout_sec?, embedding_cache_size?, embedding_cache_ttl_sec?, embedding_cache_max_bytes?, embedding_cache_normalize?, embedding_client_pool_size?, captcha_provider?, captcha_secret?, captcha_verify_url?}` — Merge the supplied fields into the current settings and persist. Omit `openai_api_key` or `captcha_secret` (or pass an empty string) to keep the existing secret. Changes are hot-reloaded immediately; cache settings are applied instantly via `reconfigure()`.
 
 ## Stats
 

+ 26 - 2
api/openapi.json

@@ -528,6 +528,15 @@
       "post": {
         "summary": "Insert a new JSON document",
         "operationId": "insertDocument",
+        "parameters": [
+          {
+            "name": "X-Captcha-Token",
+            "in": "header",
+            "required": false,
+            "schema": { "type": "string" },
+            "description": "CAPTCHA response token. Required when the scoped key rule has require_human_token=true; verified server-side via the configured captcha provider."
+          }
+        ],
         "requestBody": {
           "required": true,
           "content": {
@@ -681,6 +690,15 @@
         "summary": "Store a vector; supply either text (auto-embedded) or an explicit vector array",
         "description": "Store a vector. Supply either `text` (auto-embedded) or a pre-computed `vector` array. When supplying `text`, the server-side embedding result is cached by default. Send `Cache-Control: no-store` to bypass the cache: the text will be re-embedded fresh and the result will not be stored.",
         "operationId": "upsertVector",
+        "parameters": [
+          {
+            "name": "X-Captcha-Token",
+            "in": "header",
+            "required": false,
+            "schema": { "type": "string" },
+            "description": "CAPTCHA response token. Required when the scoped key rule has require_human_token=true; verified server-side via the configured captcha provider."
+          }
+        ],
         "requestBody": {
           "required": true,
           "content": {
@@ -880,7 +898,10 @@
                     "embedding_cache_ttl_sec": { "type": "integer" },
                     "embedding_cache_max_bytes": { "type": "integer" },
                     "embedding_cache_normalize": { "type": "boolean" },
-                    "embedding_client_pool_size": { "type": "integer" }
+                    "embedding_client_pool_size": { "type": "integer" },
+                    "captcha_provider": { "type": "string" },
+                    "captcha_secret_set": { "type": "boolean", "description": "True when a captcha_secret is configured (the secret itself is never returned)" },
+                    "captcha_verify_url": { "type": "string" }
                   }
                 }
               }
@@ -913,7 +934,10 @@
                   "embedding_cache_ttl_sec": { "type": "integer", "minimum": 0, "description": "Cache entry TTL in seconds; 0 = no expiry (default 86400)" },
                   "embedding_cache_max_bytes": { "type": "integer", "minimum": 0, "description": "Maximum total bytes of cached vectors (default 268435456 = 256 MB)" },
                   "embedding_cache_normalize": { "type": "boolean", "description": "When true, text is trimmed and lower-cased before cache lookup (default false)" },
-                  "embedding_client_pool_size": { "type": "integer", "minimum": 1, "description": "Number of keep-alive HTTP clients pooled per upstream origin (default 4)" }
+                  "embedding_client_pool_size": { "type": "integer", "minimum": 1, "description": "Number of keep-alive HTTP clients pooled per upstream origin (default 4)" },
+                  "captcha_provider": { "type": "string", "description": "CAPTCHA provider: empty string to disable, 'turnstile', or 'hcaptcha'" },
+                  "captcha_secret": { "type": "string", "description": "CAPTCHA server-side secret. Omit or pass empty string to preserve the existing secret." },
+                  "captcha_verify_url": { "type": "string", "description": "Full URL of the CAPTCHA verify endpoint (e.g. https://challenges.cloudflare.com/turnstile/v0/siteverify)" }
                 }
               }
             }

+ 77 - 0
tests/test_api_integration.cpp

@@ -1,5 +1,7 @@
 #include "api_fixture.hpp"
 #include "embedding_cache.hpp"
+#include <thread>
+#include <chrono>
 using svapi::testutil::ApiFixture;
 
 TEST_F(ApiFixture, HealthzPublic) { auto r = noAuth().Get("/healthz"); ASSERT_TRUE(r); EXPECT_EQ(r->status, 200); }
@@ -816,3 +818,78 @@ TEST_F(ApiFixture, CreateScopedKeyAndReject) {
     ASSERT_TRUE(badOp);
     EXPECT_EQ(badOp->status, 422) << "unknown op must be rejected: " << badOp->body;
 }
+
+// Task 14: CAPTCHA-gated insert: scoped key with require_human_token must enforce X-Captcha-Token.
+TEST_F(ApiFixture, CaptchaGatedInsert) {
+    // Spin up an in-process mock captcha server.
+    const std::string goodToken = "captcha-good-token-xyz";
+    httplib::Server captchaMock;
+    captchaMock.Post("/verify", [&goodToken](const httplib::Request& req, httplib::Response& res) {
+        bool ok = req.body.find(goodToken) != std::string::npos;
+        res.set_content(ok ? R"({"success":true})" : R"({"success":false})", "application/json");
+    });
+    int captchaPort = captchaMock.bind_to_any_port("127.0.0.1");
+    std::thread captchaThread([&captchaMock]{ captchaMock.listen_after_bind(); });
+    while (!captchaMock.is_running())
+        std::this_thread::sleep_for(std::chrono::milliseconds(5));
+
+    // Configure captcha settings.
+    {
+        svapi::Settings s = *settings_->snapshot();
+        s.captchaProvider  = "turnstile";
+        s.captchaSecret    = "test-secret";
+        s.captchaVerifyUrl = "http://127.0.0.1:" + std::to_string(captchaPort) + "/verify";
+        settings_->save(s);
+    }
+
+    // Create a json collection.
+    const std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(admin().Post(base.c_str(),
+        nlohmann::json{{"name","quotes"},{"kind","json"}}.dump(), "application/json")->status, 201);
+
+    // Build a scoped key with require_human_token:true on "quotes"/insert.
+    svapi::ApiKey sk;
+    sk.id        = svapi::generateApiKey();
+    sk.key       = svapi::generateApiKey();
+    sk.label     = "captcha-test";
+    sk.projects  = {project_};
+    sk.admin     = false;
+    sk.createdAt = 0;
+    {
+        svapi::KeyScope sc;
+        svapi::KeyScopeRule r;
+        r.collection       = "quotes";
+        r.ops              = {svapi::KeyOp::Insert};
+        r.requireHumanToken = true;
+        sc.rules.push_back(std::move(r));
+        sk.scope = std::move(sc);
+    }
+    db_->client().upsert(keysColl_, sk.toJson(), sk.id);
+    keys_->bootstrap("");
+
+    const std::string path = base + "/quotes/documents";
+    const std::string body = nlohmann::json{{"data",{{"q","test"}}}}.dump();
+    const std::string ct   = "application/json";
+
+    // POST without X-Captcha-Token → 403
+    auto noToken = request("POST", path, body, ct, sk.key);
+    ASSERT_TRUE(noToken);
+    EXPECT_EQ(noToken->status, 403) << "missing captcha token must be 403; body: " << noToken->body;
+
+    // POST with good token → 201
+    auto goodRes = request("POST", path, body, ct, sk.key, {{"X-Captcha-Token", goodToken}});
+    ASSERT_TRUE(goodRes);
+    EXPECT_EQ(goodRes->status, 201) << "good captcha token must be 201; body: " << goodRes->body;
+
+    // Cleanup
+    captchaMock.stop();
+    captchaThread.join();
+    admin().Delete((base + "/quotes").c_str());
+    db_->client().remove(keysColl_, sk.id);
+    // Restore captcha settings to disabled.
+    svapi::Settings s = *settings_->snapshot();
+    s.captchaProvider  = "";
+    s.captchaSecret    = "";
+    s.captchaVerifyUrl = "";
+    settings_->save(s);
+}