feat: 添加飞书集成和改进安全配置

- 集成 lark-oapi 库以支持飞书功能
- 改进 CORS 配置验证器以支持 JSON 格式输入
- 添加安全凭证检查逻辑以防止跨域安全问题
- 在 DirectLLMAdapter 中增加响应解析异常处理

fix: 增强查询参数验证和分页限制

- 为多个路由添加 Query 参数验证器
- 实现 bounded_limit 和 bounded_offset 辅助函数
- 设置查询限制范围为 1-500 之间
- 使用 secrets.compare_digest 提升令牌验证安全性

refactor: 调整文档忽略规则和测试配置

- 更新 .gitignore 文件中的文档路径配置
- 在 smoke 测试中添加必要的环境变量配置
- 重构配置验证器以提高类型兼容性
```
This commit is contained in:
2026-07-06 10:27:17 +08:00
parent e3a4a6d426
commit 9b87c6a7a3
22 changed files with 155 additions and 35 deletions

14
.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
.git
.env
.venv
__pycache__/
*.py[cod]
.pytest_cache/
logs/
docs/
migration/
README.md
read.md
AGENTS.md
AGENTS.md.bak-*

9
.gitignore vendored
View File

@@ -9,16 +9,11 @@ __pycache__/
*.log *.log
# Local docs and agent instructions # Local docs and agent instructions
/docs/* /docs/
!/docs/ai_integration.md /README.md
!/docs/mysql_integration.md
/read.md /read.md
/AGENTS.md /AGENTS.md
/AGENTS.md.bak-* /AGENTS.md.bak-*
# Local migration workspace # Local migration workspace
/migration/ /migration/
# Local generated docs
/docs/
/README.md

View File

@@ -16,6 +16,7 @@ RUN pip install --no-cache-dir \
python-dotenv==1.0.1 \ python-dotenv==1.0.1 \
alembic==1.14.0 \ alembic==1.14.0 \
httpx==0.28.1 \ httpx==0.28.1 \
lark-oapi==1.6.8 \
apscheduler==3.10.4 \ apscheduler==3.10.4 \
redis==5.2.1 \ redis==5.2.1 \
celery==5.4.0 \ celery==5.4.0 \

View File

@@ -64,10 +64,18 @@ class Settings(BaseSettings):
@field_validator("cors_origins", mode="before") @field_validator("cors_origins", mode="before")
@classmethod @classmethod
def parse_cors_origins(cls, value: str | list[str]) -> list[str]: def parse_cors_origins(cls, value: Any) -> list[str]:
if isinstance(value, list): if isinstance(value, list):
return value return [str(item).strip() for item in value if str(item).strip()]
return [item.strip() for item in value.split(",") if item.strip()] if value is None:
return []
text = str(value).strip()
if text.startswith("["):
data = json.loads(text)
if not isinstance(data, list):
raise ValueError("CORS_ORIGINS must be a CSV string or JSON list")
return [str(item).strip() for item in data if str(item).strip()]
return [item.strip() for item in text.split(",") if item.strip()]
@field_validator("openclaw_allowed_tools", "openclaw_allowed_actions", mode="before") @field_validator("openclaw_allowed_tools", "openclaw_allowed_actions", mode="before")
@classmethod @classmethod

14
app/core/pagination.py Normal file
View File

@@ -0,0 +1,14 @@
DEFAULT_MAX_LIMIT = 500
DEFAULT_MIN_LIMIT = 1
def bounded_limit(limit: int, max_limit: int = DEFAULT_MAX_LIMIT) -> int:
"""Clamp database query limits to a safe positive range."""
return max(DEFAULT_MIN_LIMIT, min(int(limit), max_limit))
def bounded_offset(offset: int) -> int:
"""Clamp pagination offsets to zero or above."""
return max(0, int(offset))

View File

@@ -6,6 +6,10 @@ from app.core.config import get_settings
from app.core.scheduler import attach_scheduler from app.core.scheduler import attach_scheduler
def _allow_cors_credentials(cors_origins: list[str]) -> bool:
return "*" not in cors_origins
def create_app() -> FastAPI: def create_app() -> FastAPI:
"""Create and configure the FastAPI application.""" """Create and configure the FastAPI application."""
@@ -23,7 +27,7 @@ def create_app() -> FastAPI:
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=settings.cors_origins, allow_origins=settings.cors_origins,
allow_credentials=True, allow_credentials=_allow_cors_credentials(settings.cors_origins),
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )

View File

@@ -350,9 +350,18 @@ class DirectLLMAdapter(AIAdapter):
if response.status_code >= 400: if response.status_code >= 400:
raise HTTPException(status_code=502, detail={AIErrorKey.DIRECT_LLM: response.text}) raise HTTPException(status_code=502, detail={AIErrorKey.DIRECT_LLM: response.text})
data = response.json() data = response.json()
answer = data[AIHttpPayloadKey.CHOICES][0][AIHttpPayloadKey.MESSAGE][ try:
AIHttpPayloadKey.CONTENT answer = data[AIHttpPayloadKey.CHOICES][0][AIHttpPayloadKey.MESSAGE][
] AIHttpPayloadKey.CONTENT
]
except (KeyError, IndexError, TypeError) as exc:
raise HTTPException(
status_code=502,
detail={
AIErrorKey.DIRECT_LLM: UNEXPECTED_HERMES_RESPONSE,
AIResponseKey.RAW: data,
},
) from exc
return {AIResponseKey.ANSWER: answer, AIResponseKey.RAW: data} return {AIResponseKey.ANSWER: answer, AIResponseKey.RAW: data}

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
@@ -21,7 +21,7 @@ def create_approval(
@router.get("", response_model=list[ApprovalRead]) @router.get("", response_model=list[ApprovalRead])
def list_approvals( def list_approvals(
status: str | None = None, status: str | None = None,
limit: int = 100, limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
return ApprovalService(db).list(status_filter=status, limit=limit) return ApprovalService(db).list(status_filter=status, limit=limit)

View File

@@ -8,6 +8,7 @@ from fastapi import HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.pagination import bounded_limit
from app.core.time import utc_now from app.core.time import utc_now
from app.modules.approvals.constants import ( from app.modules.approvals.constants import (
ApprovalActionValue, ApprovalActionValue,
@@ -56,7 +57,11 @@ class ApprovalService:
return ticket return ticket
def list(self, status_filter: str | None = None, limit: int = 100) -> list[ApprovalRequest]: def list(self, status_filter: str | None = None, limit: int = 100) -> list[ApprovalRequest]:
stmt = select(ApprovalRequest).order_by(ApprovalRequest.id.desc()).limit(min(limit, 500)) stmt = (
select(ApprovalRequest)
.order_by(ApprovalRequest.id.desc())
.limit(bounded_limit(limit))
)
if status_filter: if status_filter:
stmt = stmt.where(ApprovalRequest.status == status_filter) stmt = stmt.where(ApprovalRequest.status == status_filter)
return list(self.db.execute(stmt).scalars()) return list(self.db.execute(stmt).scalars())

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
@@ -10,5 +10,8 @@ router = APIRouter(dependencies=[Depends(require_api_key)])
@router.get("/logs", response_model=list[AuditLogRead]) @router.get("/logs", response_model=list[AuditLogRead])
def list_audit_logs(limit: int = 100, db: Session = Depends(get_db)) -> list: def list_audit_logs(
limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db),
) -> list:
return AuditService(db).list_logs(limit=limit) return AuditService(db).list_logs(limit=limit)

View File

@@ -4,6 +4,7 @@ from typing import Any
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.pagination import bounded_limit
from app.modules.audit.models import AuditLog from app.modules.audit.models import AuditLog
from app.modules.audit.schemas import AuditLogCreate from app.modules.audit.schemas import AuditLogCreate
@@ -42,5 +43,5 @@ class AuditService:
return record return record
def list_logs(self, limit: int = 100) -> list[AuditLog]: def list_logs(self, limit: int = 100) -> list[AuditLog]:
stmt = select(AuditLog).order_by(AuditLog.id.desc()).limit(min(limit, 500)) stmt = select(AuditLog).order_by(AuditLog.id.desc()).limit(bounded_limit(limit))
return list(self.db.execute(stmt).scalars()) return list(self.db.execute(stmt).scalars())

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
@@ -18,8 +18,8 @@ def list_domains() -> dict[str, list[str]]:
@router.get("/{domain}", response_model=DomainListRead) @router.get("/{domain}", response_model=DomainListRead)
def list_records( def list_records(
domain: str, domain: str,
limit: int = 50, limit: int = Query(default=50, ge=1, le=500),
offset: int = 0, offset: int = Query(default=0, ge=0),
status: str | None = None, status: str | None = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:

View File

@@ -11,6 +11,7 @@ from sqlalchemy.sql.schema import Column
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.constants import ActorValue from app.core.constants import ActorValue
from app.core.pagination import bounded_limit, bounded_offset
from app.modules.audit.constants import AuditRiskLevel, AuditSource from app.modules.audit.constants import AuditRiskLevel, AuditSource
from app.modules.audit.schemas import AuditLogCreate from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService from app.modules.audit.service import AuditService
@@ -89,7 +90,9 @@ class BusinessService:
if status_filter and hasattr(model, "status"): if status_filter and hasattr(model, "status"):
stmt = stmt.where(model.status == status_filter) stmt = stmt.where(model.status == status_filter)
count_stmt = count_stmt.where(model.status == status_filter) count_stmt = count_stmt.where(model.status == status_filter)
stmt = stmt.order_by(model.id.desc()).limit(min(limit, 500)).offset(max(offset, 0)) stmt = stmt.order_by(model.id.desc()).limit(bounded_limit(limit)).offset(
bounded_offset(offset)
)
total = int(self.db.execute(count_stmt).scalar() or 0) total = int(self.db.execute(count_stmt).scalar() or 0)
return total, [serialize_model(item) for item in self.db.execute(stmt).scalars()] return total, [serialize_model(item) for item in self.db.execute(stmt).scalars()]

View File

@@ -1,3 +1,4 @@
from secrets import compare_digest
from typing import Any from typing import Any
from fastapi import HTTPException, status from fastapi import HTTPException, status
@@ -30,7 +31,7 @@ class FeishuService:
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="FEISHU_VERIFICATION_TOKEN is required", detail="FEISHU_VERIFICATION_TOKEN is required",
) )
if not token or token != expected: if not token or not compare_digest(str(token), expected):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid Feishu token", detail="Invalid Feishu token",

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
@@ -38,7 +38,10 @@ def readonly_query(payload: ReadonlyQueryRequest, db: Session = Depends(get_db))
@router.get("/projects", response_model=QueryResult) @router.get("/projects", response_model=QueryResult)
def default_project_query(limit: int = 100, db: Session = Depends(get_db)) -> dict: def default_project_query(
limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db),
) -> dict:
return LegacyMySQLService(db).fetch_default_projects(limit=limit) return LegacyMySQLService(db).fetch_default_projects(limit=limit)

View File

@@ -12,6 +12,7 @@ from sqlalchemy.orm import Session
from app.core.constants import ActorValue from app.core.constants import ActorValue
from app.core.config import get_settings from app.core.config import get_settings
from app.core.database import legacy_engine from app.core.database import legacy_engine
from app.core.pagination import bounded_limit
from app.core.time import utc_now from app.core.time import utc_now
from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource, AuditStatus from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource, AuditStatus
from app.modules.audit.schemas import AuditLogCreate from app.modules.audit.schemas import AuditLogCreate
@@ -174,7 +175,7 @@ class LegacyMySQLService:
self._ensure_readonly(sql) self._ensure_readonly(sql)
engine = self._ensure_engine() engine = self._ensure_engine()
params = dict(params or {}) params = dict(params or {})
params.setdefault("limit", min(limit, 500)) params.setdefault("limit", bounded_limit(limit))
limited_sql = sql limited_sql = sql
if " limit " not in sql.lower(): if " limit " not in sql.lower():
limited_sql = f"{sql.rstrip(';')} LIMIT :limit" limited_sql = f"{sql.rstrip(';')} LIMIT :limit"

View File

@@ -6,6 +6,7 @@ from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.constants import ActorValue from app.core.constants import ActorValue
from app.core.pagination import bounded_limit
from app.core.time import utc_now from app.core.time import utc_now
from app.modules.audit.constants import AuditSource, AuditTargetType from app.modules.audit.constants import AuditSource, AuditTargetType
from app.modules.audit.schemas import AuditLogCreate from app.modules.audit.schemas import AuditLogCreate
@@ -137,7 +138,7 @@ class ReportService:
stmt = stmt.order_by(order_by) stmt = stmt.order_by(order_by)
else: else:
stmt = stmt.order_by(model.id.desc()) stmt = stmt.order_by(model.id.desc())
return list(self.db.execute(stmt.limit(limit)).scalars()) return list(self.db.execute(stmt.limit(bounded_limit(limit))).scalars())
def daily_brief(self) -> dict: def daily_brief(self) -> dict:
project_count = self._count(Project) project_count = self._count(Project)

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
@@ -40,7 +40,7 @@ def supplier_risks(db: Session = Depends(get_db)) -> dict:
@router.get("/events") @router.get("/events")
def risk_events( def risk_events(
limit: int = 100, limit: int = Query(default=100, ge=1, le=500),
status: str | None = None, status: str | None = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:

View File

@@ -6,6 +6,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.constants import ActorValue from app.core.constants import ActorValue
from app.core.pagination import bounded_limit
from app.core.time import utc_now from app.core.time import utc_now
from app.modules.audit.constants import ( from app.modules.audit.constants import (
AuditAction, AuditAction,
@@ -75,13 +76,14 @@ class RiskService:
limit: int = 100, limit: int = 100,
status_filter: str | None = None, status_filter: str | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
stmt = select(RiskEvent).order_by(RiskEvent.id.desc()).limit(min(limit, 500)) limit_value = bounded_limit(limit)
stmt = select(RiskEvent).order_by(RiskEvent.id.desc()).limit(limit_value)
if status_filter: if status_filter:
stmt = ( stmt = (
select(RiskEvent) select(RiskEvent)
.where(RiskEvent.status == status_filter) .where(RiskEvent.status == status_filter)
.order_by(RiskEvent.id.desc()) .order_by(RiskEvent.id.desc())
.limit(min(limit, 500)) .limit(limit_value)
) )
return [serialize_model(item) for item in self.db.execute(stmt).scalars()] return [serialize_model(item) for item in self.db.execute(stmt).scalars()]

View File

@@ -11,6 +11,9 @@ db.close()
os.environ["DATABASE_URL"] = "sqlite:///" + db.name.replace("\\", "/") os.environ["DATABASE_URL"] = "sqlite:///" + db.name.replace("\\", "/")
os.environ["API_KEY"] = "test-key" os.environ["API_KEY"] = "test-key"
os.environ["LEGACY_ALLOWED_QUERIES"] = "{}"
os.environ["LEGACY_DATABASE_URL"] = ""
os.environ["LEGACY_PROJECT_QUERY"] = ""
os.environ["MODEL_PROVIDER"] = "noop" os.environ["MODEL_PROVIDER"] = "noop"
os.environ["SCHEDULER_ENABLED"] = "false" os.environ["SCHEDULER_ENABLED"] = "false"

View File

@@ -9,6 +9,7 @@ from app.modules.ai_agent.constants import (
OPENCLAW_HERMES_PIPELINE, OPENCLAW_HERMES_PIPELINE,
AIChatRole, AIChatRole,
AIContextKey, AIContextKey,
AIErrorKey,
AIHttpHeader, AIHttpHeader,
AIHttpPath, AIHttpPath,
AIHttpPayloadKey, AIHttpPayloadKey,
@@ -237,3 +238,30 @@ def test_openclaw_hermes_adapter_fails_when_requested_tool_is_blocked(monkeypatc
"http://openclaw.local/healthz", "http://openclaw.local/healthz",
"http://openclaw.local/readyz", "http://openclaw.local/readyz",
] ]
def test_direct_llm_adapter_wraps_unexpected_chat_response(monkeypatch) -> None:
class BadChatClient(DummyClient):
def post(
self,
url: str,
json: dict[str, Any],
headers: dict[str, str],
) -> DummyResponse:
self.calls.append({"method": "POST", "url": url, "json": json, "headers": headers})
return DummyResponse({AIHttpPayloadKey.CHOICES: []})
DummyClient.calls = []
monkeypatch.setattr(adapters.httpx, "Client", BadChatClient)
settings = Settings(
direct_llm_base_url="http://llm.local/v1",
direct_llm_api_key="direct-key",
direct_llm_model="company-model",
)
with pytest.raises(HTTPException) as exc_info:
adapters.DirectLLMAdapter(settings).ask("summarize")
assert exc_info.value.status_code == 502
assert exc_info.value.detail[AIErrorKey.DIRECT_LLM] == "Unexpected chat completion response"
assert exc_info.value.detail[AIResponseKey.RAW] == {AIHttpPayloadKey.CHOICES: []}

View File

@@ -20,15 +20,19 @@ os.environ["APPROVAL_API_ACTOR"] = "approval-manager"
os.environ["FEISHU_APP_ID"] = "" os.environ["FEISHU_APP_ID"] = ""
os.environ["FEISHU_APP_SECRET"] = "" os.environ["FEISHU_APP_SECRET"] = ""
os.environ["FEISHU_VERIFICATION_TOKEN"] = "test-feishu-token" os.environ["FEISHU_VERIFICATION_TOKEN"] = "test-feishu-token"
os.environ["LEGACY_ALLOWED_QUERIES"] = "{}"
os.environ["LEGACY_DATABASE_URL"] = ""
os.environ["LEGACY_PROJECT_QUERY"] = ""
os.environ["MODEL_PROVIDER"] = AIProviderName.NOOP os.environ["MODEL_PROVIDER"] = AIProviderName.NOOP
os.environ["SCHEDULER_ENABLED"] = "false" os.environ["SCHEDULER_ENABLED"] = "false"
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.core.config import get_settings from app.core.config import Settings, get_settings
from app.core.database import Base, engine from app.core.database import Base, engine
from app.core.pagination import bounded_limit, bounded_offset
from app.core.security import require_api_key, require_approval_api_key from app.core.security import require_api_key, require_approval_api_key
from app.main import app from app.main import _allow_cors_credentials, app
from app.modules.legacy_mysql.service import LegacyMySQLService from app.modules.legacy_mysql.service import LegacyMySQLService
from app.modules.reports.constants import ( from app.modules.reports.constants import (
LifecycleAttentionKey, LifecycleAttentionKey,
@@ -133,6 +137,26 @@ def test_api_key_and_feishu_webhook_fail_closed(monkeypatch) -> None:
get_settings.cache_clear() get_settings.cache_clear()
def test_config_and_pagination_guardrails() -> None:
settings = Settings(cors_origins='["https://app.example.com", "https://admin.example.com"]')
assert settings.cors_origins == ["https://app.example.com", "https://admin.example.com"]
assert _allow_cors_credentials(["*"]) is False
assert _allow_cors_credentials(["https://app.example.com"]) is True
assert bounded_limit(-1) == 1
assert bounded_limit(1000) == 500
assert bounded_offset(-10) == 0
negative_limit_response = client.get("/api/v1/business/projects?limit=-1", headers=headers)
assert negative_limit_response.status_code == 422
oversized_limit_response = client.get("/api/v1/risks/events?limit=501", headers=headers)
assert oversized_limit_response.status_code == 422
negative_offset_response = client.get("/api/v1/business/projects?offset=-1", headers=headers)
assert negative_offset_response.status_code == 422
def test_approval_gate_for_high_risk_update() -> None: def test_approval_gate_for_high_risk_update() -> None:
create_payload = { create_payload = {
"code": "FUND-SMOKE-001", "code": "FUND-SMOKE-001",