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

View File

@@ -64,10 +64,18 @@ class Settings(BaseSettings):
@field_validator("cors_origins", mode="before")
@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):
return value
return [item.strip() for item in value.split(",") if item.strip()]
return [str(item).strip() for item in value if str(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")
@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
def _allow_cors_credentials(cors_origins: list[str]) -> bool:
return "*" not in cors_origins
def create_app() -> FastAPI:
"""Create and configure the FastAPI application."""
@@ -23,7 +27,7 @@ def create_app() -> FastAPI:
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_credentials=_allow_cors_credentials(settings.cors_origins),
allow_methods=["*"],
allow_headers=["*"],
)

View File

@@ -350,9 +350,18 @@ class DirectLLMAdapter(AIAdapter):
if response.status_code >= 400:
raise HTTPException(status_code=502, detail={AIErrorKey.DIRECT_LLM: response.text})
data = response.json()
answer = data[AIHttpPayloadKey.CHOICES][0][AIHttpPayloadKey.MESSAGE][
AIHttpPayloadKey.CONTENT
]
try:
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}

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.core.database import get_db
@@ -21,7 +21,7 @@ def create_approval(
@router.get("", response_model=list[ApprovalRead])
def list_approvals(
status: str | None = None,
limit: int = 100,
limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db),
):
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.orm import Session
from app.core.pagination import bounded_limit
from app.core.time import utc_now
from app.modules.approvals.constants import (
ApprovalActionValue,
@@ -56,7 +57,11 @@ class ApprovalService:
return ticket
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:
stmt = stmt.where(ApprovalRequest.status == status_filter)
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 app.core.database import get_db
@@ -10,5 +10,8 @@ router = APIRouter(dependencies=[Depends(require_api_key)])
@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)

View File

@@ -4,6 +4,7 @@ from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.pagination import bounded_limit
from app.modules.audit.models import AuditLog
from app.modules.audit.schemas import AuditLogCreate
@@ -42,5 +43,5 @@ class AuditService:
return record
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())

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

View File

@@ -11,6 +11,7 @@ from sqlalchemy.sql.schema import Column
from sqlalchemy.orm import Session
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.schemas import AuditLogCreate
from app.modules.audit.service import AuditService
@@ -89,7 +90,9 @@ class BusinessService:
if status_filter and hasattr(model, "status"):
stmt = 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)
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 fastapi import HTTPException, status
@@ -30,7 +31,7 @@ class FeishuService:
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="FEISHU_VERIFICATION_TOKEN is required",
)
if not token or token != expected:
if not token or not compare_digest(str(token), expected):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
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 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)
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)

View File

@@ -12,6 +12,7 @@ from sqlalchemy.orm import Session
from app.core.constants import ActorValue
from app.core.config import get_settings
from app.core.database import legacy_engine
from app.core.pagination import bounded_limit
from app.core.time import utc_now
from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource, AuditStatus
from app.modules.audit.schemas import AuditLogCreate
@@ -174,7 +175,7 @@ class LegacyMySQLService:
self._ensure_readonly(sql)
engine = self._ensure_engine()
params = dict(params or {})
params.setdefault("limit", min(limit, 500))
params.setdefault("limit", bounded_limit(limit))
limited_sql = sql
if " limit " not in sql.lower():
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 app.core.constants import ActorValue
from app.core.pagination import bounded_limit
from app.core.time import utc_now
from app.modules.audit.constants import AuditSource, AuditTargetType
from app.modules.audit.schemas import AuditLogCreate
@@ -137,7 +138,7 @@ class ReportService:
stmt = stmt.order_by(order_by)
else:
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:
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 app.core.database import get_db
@@ -40,7 +40,7 @@ def supplier_risks(db: Session = Depends(get_db)) -> dict:
@router.get("/events")
def risk_events(
limit: int = 100,
limit: int = Query(default=100, ge=1, le=500),
status: str | None = None,
db: Session = Depends(get_db),
) -> dict:

View File

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