Skip to content

Commit 42e0e0b

Browse files
CopilotZhuLinsen
authored andcommitted
fix: add optional CLAWBOT_SECRET guard to clawbot endpoint
Add X-ClawBot-Secret header validation to /api/v1/clawbot/message. When CLAWBOT_SECRET env var is set, callers must send the matching value in the header; unmatched requests receive 401. If CLAWBOT_SECRET is not set, the endpoint remains open (backwards compatible with existing deployments). Also document CLAWBOT_SECRET in .env.example and add two tests covering the auth-pass and auth-reject paths.
1 parent 8785ad5 commit 42e0e0b

3 files changed

Lines changed: 97 additions & 2 deletions

File tree

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,13 @@ WEBUI_AUTO_BUILD=true
446446
ADMIN_AUTH_ENABLED=false
447447
# ADMIN_SESSION_MAX_AGE_HOURS=24 # Session 有效期(小时)
448448

449+
# ===========================================
450+
# ClawBot API 接入保护(可选)
451+
# ===========================================
452+
# 设置后,调用 /api/v1/clawbot/message 时必须在请求头携带 X-ClawBot-Secret: <值>
453+
# 不设置或留空则端点对所有请求开放(适合内网部署或已有其他访问控制的场景)
454+
# CLAWBOT_SECRET=your_shared_secret_here
455+
449456
# ===========================================
450457
# 图片识别股票代码(设置页「从图片添加」)
451458
# ===========================================

api/v1/endpoints/clawbot.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
import uuid
1010
from typing import Any, Dict, List, Literal, Optional
1111

12-
from fastapi import APIRouter, HTTPException
12+
import os
13+
14+
from fastapi import APIRouter, HTTPException, Request
1315
from pydantic import BaseModel, Field
1416

1517
from api.v1.endpoints.agent import _build_executor
@@ -450,16 +452,24 @@ def _run_agent(request: ClawBotMessageRequest) -> ClawBotMessageResponse:
450452
responses={
451453
200: {"description": "ClawBot 文本响应", "model": ClawBotMessageResponse},
452454
400: {"description": "请求参数错误或能力不可用", "model": ErrorResponse},
455+
401: {"description": "X-ClawBot-Secret 校验失败(仅在配置 CLAWBOT_SECRET 时生效)", "model": ErrorResponse},
453456
422: {"description": "请求体验证失败", "model": ErrorResponse},
454457
500: {"description": "分析或 Agent 执行失败", "model": ErrorResponse},
455458
},
456459
summary="ClawBot 文本桥接",
457460
description="为微信/openclaw ClawBot 提供稳定的文本入参与文本出参桥接层。",
458461
)
459-
def handle_clawbot_message(request: ClawBotMessageRequest) -> ClawBotMessageResponse:
462+
def handle_clawbot_message(http_request: Request, request: ClawBotMessageRequest) -> ClawBotMessageResponse:
460463
"""
461464
Bridge WeChat/openclaw ClawBot requests to existing analysis/agent capabilities.
462465
"""
466+
# Optional shared-secret guard: set CLAWBOT_SECRET to protect the endpoint.
467+
# If configured, callers must send the matching value in X-ClawBot-Secret.
468+
clawbot_secret = os.environ.get("CLAWBOT_SECRET", "").strip()
469+
if clawbot_secret:
470+
provided = http_request.headers.get("X-ClawBot-Secret", "")
471+
if provided != clawbot_secret:
472+
_raise_clawbot_error(401, "unauthorized", "X-ClawBot-Secret 校验失败")
463473
try:
464474
if not request.message.strip():
465475
_raise_clawbot_error(

tests/test_clawbot_api.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ def test_clawbot_message_routes_to_analysis_and_formats_text():
8585
return_value=analysis_result,
8686
) as handle_analysis:
8787
response = handle_clawbot_message(
88+
_build_request(),
8889
ClawBotMessageRequest(message="帮我分析贵州茅台", mode="analysis")
8990
)
9091

@@ -114,6 +115,7 @@ def test_clawbot_message_routes_to_agent_with_stable_session_id():
114115
with patch("api.v1.endpoints.clawbot.get_config", return_value=config), \
115116
patch("api.v1.endpoints.clawbot._build_executor", return_value=executor):
116117
response = handle_clawbot_message(
118+
_build_request(),
117119
ClawBotMessageRequest(
118120
message="用缠论分析 600519",
119121
mode="agent",
@@ -148,6 +150,7 @@ def test_clawbot_message_auto_mode_falls_back_to_agent_for_plain_english_text():
148150
patch("api.v1.endpoints.clawbot.CommandDispatcher._resolve_stock_code_from_text", return_value=None), \
149151
patch("api.v1.endpoints.clawbot._handle_sync_analysis") as handle_analysis:
150152
response = handle_clawbot_message(
153+
_build_request(),
151154
ClawBotMessageRequest(message="I need advice", mode="auto", user_id="wx_user_002")
152155
)
153156

@@ -173,6 +176,7 @@ def test_clawbot_message_auto_mode_falls_back_to_agent_for_uppercase_english_tex
173176
patch("api.v1.endpoints.clawbot.CommandDispatcher._resolve_stock_code_from_text", return_value=None), \
174177
patch("api.v1.endpoints.clawbot._handle_sync_analysis") as handle_analysis:
175178
response = handle_clawbot_message(
179+
_build_request(),
176180
ClawBotMessageRequest(message="I NEED ADVICE", mode="auto", user_id="wx_user_004")
177181
)
178182

@@ -210,6 +214,7 @@ def test_clawbot_message_auto_mode_routes_direct_ascii_ticker_to_analysis(
210214
return_value=analysis_result,
211215
) as handle_analysis:
212216
response = handle_clawbot_message(
217+
_build_request(),
213218
ClawBotMessageRequest(message=message, mode="auto")
214219
)
215220

@@ -250,6 +255,7 @@ def test_clawbot_message_auto_mode_routes_english_request_with_ascii_ticker_to_a
250255
return_value=analysis_result,
251256
) as handle_analysis:
252257
response = handle_clawbot_message(
258+
_build_request(),
253259
ClawBotMessageRequest(message=message, mode="auto")
254260
)
255261

@@ -267,6 +273,7 @@ def test_clawbot_analysis_mode_rejects_plain_english_text():
267273
"""mode=analysis should return unresolved_stock for non-stock plain text."""
268274
try:
269275
handle_clawbot_message(
276+
_build_request(),
270277
ClawBotMessageRequest(message="I need advice", mode="analysis")
271278
)
272279
assert False, "Expected HTTPException"
@@ -282,6 +289,7 @@ def test_clawbot_message_returns_consistent_error_when_agent_unavailable():
282289
with patch("api.v1.endpoints.clawbot._build_executor") as build_executor:
283290
try:
284291
handle_clawbot_message(
292+
_build_request(),
285293
ClawBotMessageRequest(message="用缠论分析 600519", mode="agent")
286294
)
287295
assert False, "Expected HTTPException"
@@ -312,6 +320,7 @@ def test_clawbot_message_validation_handler_returns_error_response_shape():
312320
def test_clawbot_message_http_handler_normalizes_analysis_validation_error():
313321
try:
314322
handle_clawbot_message(
323+
_build_request(),
315324
ClawBotMessageRequest(message="??", mode="analysis", stock_code="??")
316325
)
317326
assert False, "Expected HTTPException"
@@ -331,6 +340,7 @@ def test_clawbot_message_http_handler_wraps_executor_exception_as_agent_failed()
331340
patch("api.v1.endpoints.clawbot._build_executor", side_effect=RuntimeError("executor boom")):
332341
try:
333342
handle_clawbot_message(
343+
_build_request(),
334344
ClawBotMessageRequest(
335345
message="用缠论分析 600519",
336346
mode="agent",
@@ -362,6 +372,7 @@ def test_clawbot_auto_mode_rejects_plain_english_word_as_direct_ticker(word: str
362372
patch("api.v1.endpoints.clawbot.CommandDispatcher._resolve_stock_code_from_text", return_value=None), \
363373
patch("api.v1.endpoints.clawbot._handle_sync_analysis") as handle_analysis:
364374
response = handle_clawbot_message(
375+
_build_request(),
365376
ClawBotMessageRequest(message=word, mode="auto")
366377
)
367378

@@ -384,6 +395,7 @@ def test_clawbot_analysis_mode_resolves_word_like_ticker():
384395
return_value=analysis_result,
385396
) as handle_analysis:
386397
response = handle_clawbot_message(
398+
_build_request(),
387399
ClawBotMessageRequest(message="SHOP", mode="analysis")
388400
)
389401

@@ -410,6 +422,7 @@ def test_clawbot_auto_mode_routes_analyze_word_like_ticker_to_analysis():
410422
return_value=analysis_result,
411423
) as handle_analysis:
412424
response = handle_clawbot_message(
425+
_build_request(),
413426
ClawBotMessageRequest(message="analyze SHOP", mode="auto")
414427
)
415428

@@ -446,6 +459,7 @@ def test_clawbot_auto_mode_routes_single_letter_ticker_in_text(
446459
return_value=analysis_result,
447460
) as handle_analysis:
448461
response = handle_clawbot_message(
462+
_build_request(),
449463
ClawBotMessageRequest(message=message, mode="auto")
450464
)
451465

@@ -481,9 +495,73 @@ def test_clawbot_auto_mode_resolves_lowercase_ticker_in_free_text(
481495
return_value=analysis_result,
482496
) as handle_analysis:
483497
response = handle_clawbot_message(
498+
_build_request(),
484499
ClawBotMessageRequest(message=message, mode="auto")
485500
)
486501

487502
assert response.mode == "analysis"
488503
assert response.stock_code == expected_code
489504
handle_analysis.assert_called_once()
505+
506+
507+
def test_clawbot_message_rejects_request_when_secret_mismatch():
508+
"""When CLAWBOT_SECRET is set, mismatched header should return 401."""
509+
import os
510+
from starlette.datastructures import Headers
511+
512+
def _build_request_with_header(secret_value: str) -> Request:
513+
encoded = secret_value.encode()
514+
return Request(
515+
{
516+
"type": "http",
517+
"http_version": "1.1",
518+
"method": "POST",
519+
"scheme": "http",
520+
"path": "/api/v1/clawbot/message",
521+
"raw_path": b"/api/v1/clawbot/message",
522+
"query_string": b"",
523+
"headers": [(b"x-clawbot-secret", encoded)],
524+
"client": ("testclient", 50000),
525+
"server": ("testserver", 80),
526+
}
527+
)
528+
529+
with patch.dict(os.environ, {"CLAWBOT_SECRET": "correct_secret"}):
530+
# Wrong secret → 401
531+
try:
532+
handle_clawbot_message(
533+
_build_request_with_header("wrong_secret"),
534+
ClawBotMessageRequest(message="分析茅台", mode="auto"),
535+
)
536+
assert False, "Expected HTTPException 401"
537+
except HTTPException as exc:
538+
assert exc.status_code == 401
539+
assert exc.detail["error"] == "unauthorized"
540+
541+
# Correct secret → proceeds past auth (agent unavailable → 400 is fine)
542+
config = SimpleNamespace(is_agent_available=lambda: False)
543+
with patch("api.v1.endpoints.clawbot.get_config", return_value=config):
544+
try:
545+
handle_clawbot_message(
546+
_build_request_with_header("correct_secret"),
547+
ClawBotMessageRequest(message="分析茅台", mode="auto"),
548+
)
549+
except HTTPException as exc:
550+
assert exc.status_code != 401, "Should not get 401 with correct secret"
551+
552+
553+
def test_clawbot_message_allows_request_when_no_secret_configured():
554+
"""When CLAWBOT_SECRET is not set, all requests should proceed past auth."""
555+
import os
556+
557+
env_without_secret = {k: v for k, v in os.environ.items() if k != "CLAWBOT_SECRET"}
558+
with patch.dict(os.environ, env_without_secret, clear=True):
559+
config = SimpleNamespace(is_agent_available=lambda: False)
560+
with patch("api.v1.endpoints.clawbot.get_config", return_value=config):
561+
try:
562+
handle_clawbot_message(
563+
_build_request(),
564+
ClawBotMessageRequest(message="分析茅台", mode="auto"),
565+
)
566+
except HTTPException as exc:
567+
assert exc.status_code != 401, "No secret configured, should not get 401"

0 commit comments

Comments
 (0)