feat(security): Phase 20 CSRF 防護實作

Phase 19 首席架構師審查指出: 核鑰 UX 安全性缺 CSRF 防護

後端:
- 新增 src/core/csrf.py (Double Submit Cookie 模式)
- 新增 src/api/v1/csrf.py (GET /api/v1/csrf/token)
- 新增 src/models/csrf.py (CSRFTokenResponse)
- 修改 approvals.py sign/reject/bulk 端點加入 CSRFToken 驗證

前端:
- 新增 hooks/useCSRF.ts (React Hook)
- 修改 approval.store.ts 整合 CSRF Token 參數

安全特性:
- 256-bit Token (secrets.token_hex)
- 時序安全比較 (secrets.compare_digest)
- SameSite=Strict Cookie
- 1 小時 Token 有效期

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-03-28 18:31:58 +08:00
parent cd305a0baf
commit d206460751
10 changed files with 503 additions and 11 deletions

View File

@@ -35,6 +35,7 @@ from fastapi import (
from fastapi.responses import StreamingResponse
from src.core.config import settings
from src.core.csrf import CSRFToken # Phase 20: CSRF Protection
from src.core.logging import get_logger
from src.core.sse import EventType, SSEEvent, get_publisher
from src.models.approval import (
@@ -264,6 +265,7 @@ async def sign_approval(
approval_id: UUID,
request: SignRequest,
background_tasks: BackgroundTasks,
_csrf_token: CSRFToken, # Phase 20: CSRF Protection (驗證用,不需要使用值)
) -> SignResponse:
"""
簽核授權請求 (Phase 5: Database + K8s Execution)
@@ -404,6 +406,7 @@ async def sign_approval(
async def reject_approval(
approval_id: UUID,
request: RejectRequest,
_csrf_token: CSRFToken, # Phase 20: CSRF Protection
) -> ApprovalRequestResponse:
"""
拒絕授權請求 (Phase 5: Database)
@@ -506,6 +509,7 @@ async def reject_approval(
async def bulk_approve(
request: BulkApproveRequest,
background_tasks: BackgroundTasks,
_csrf_token: CSRFToken, # Phase 20: CSRF Protection
) -> BulkApproveResponse:
"""
批次簽核授權請求 (Phase 11)

View File

@@ -0,0 +1,62 @@
"""
CSRF Token API Endpoint (Phase 20)
==================================
提供 CSRF Token 給前端,用於保護敏感操作。
端點:
- GET /api/v1/csrf/token - 取得 CSRF Token
前端使用流程:
1. 頁面載入時呼叫 GET /api/v1/csrf/token
2. 將返回的 token 儲存在 state 或 memory 中
3. 敏感請求 (sign/reject) 時帶上 X-CSRF-Token header
建立日期: 2026-03-28 (台北時間)
建立者: Claude Code (首席架構師)
"""
from fastapi import APIRouter, Response
from src.core.csrf import CSRF_COOKIE_NAME, generate_csrf_token, set_csrf_cookie
from src.core.logging import get_logger
from src.models.csrf import CSRFTokenResponse
router = APIRouter(prefix="/csrf", tags=["Security"])
logger = get_logger("awoooi.csrf")
@router.get(
"/token",
response_model=CSRFTokenResponse,
summary="取得 CSRF Token",
description="取得用於保護敏感操作的 CSRF Token。Token 同時設定在 Cookie 和 Response Body 中。",
)
async def get_csrf_token(response: Response) -> CSRFTokenResponse:
"""
取得 CSRF Token
此端點會:
1. 生成新的 CSRF Token
2. 設定 awoooi_csrf_token Cookie
3. 在 Response Body 中返回 Token
前端應:
1. 呼叫此端點取得 Token
2. 在敏感請求中帶上 X-CSRF-Token header
Returns:
CSRFTokenResponse: 包含 CSRF Token 的回應
"""
token = generate_csrf_token()
set_csrf_cookie(response, token)
logger.info(
"csrf_token_issued",
token_preview=token[:8] + "...",
)
return CSRFTokenResponse(
token=token,
cookie_name=CSRF_COOKIE_NAME,
header_name="X-CSRF-Token",
)

178
apps/api/src/core/csrf.py Normal file
View File

@@ -0,0 +1,178 @@
"""
CSRF Protection Module (Phase 20)
=================================
Phase 19 首席架構師審查: 核鑰 UX 缺 CSRF 防護 (9/10)
實作模式: Double Submit Cookie
- 後端生成 CSRF Token設定在 Cookie 中
- 前端在敏感請求 Header 中帶上相同 Token
- 後端驗證 Cookie 和 Header 的 Token 是否匹配
保護端點:
- POST /api/v1/approvals/{id}/sign - 核鑰簽核
- POST /api/v1/approvals/{id}/reject - 拒絕請求
- POST /api/v1/approvals/bulk - 批次處理
建立日期: 2026-03-28 (台北時間)
建立者: Claude Code (首席架構師)
"""
import secrets
from typing import Annotated
from fastapi import Cookie, Depends, Header, HTTPException, Request, Response, status
from src.core.config import settings
from src.core.logging import get_logger
logger = get_logger("awoooi.csrf")
# =============================================================================
# Constants
# =============================================================================
CSRF_COOKIE_NAME = "awoooi_csrf_token"
CSRF_HEADER_NAME = "X-CSRF-Token"
CSRF_TOKEN_LENGTH = 32 # 256 bits of entropy
# =============================================================================
# Token Generation
# =============================================================================
def generate_csrf_token() -> str:
"""
生成加密安全的 CSRF Token
Returns:
str: 64 字元的十六進位 Token (32 bytes = 256 bits)
"""
return secrets.token_hex(CSRF_TOKEN_LENGTH)
# =============================================================================
# Token Verification (Dependency Injection)
# =============================================================================
async def verify_csrf_token(
request: Request,
x_csrf_token: Annotated[str | None, Header(alias=CSRF_HEADER_NAME)] = None,
csrf_cookie: Annotated[str | None, Cookie(alias=CSRF_COOKIE_NAME)] = None,
) -> str:
"""
驗證 CSRF Token (FastAPI Depends)
Double Submit Cookie 模式:
1. 從 Cookie 取得 Token
2. 從 Header 取得 Token
3. 比對兩者是否一致
Args:
request: FastAPI Request 物件
x_csrf_token: X-CSRF-Token Header
csrf_cookie: awoooi_csrf_token Cookie
Returns:
str: 驗證通過的 CSRF Token
Raises:
HTTPException: 403 CSRF 驗證失敗
"""
# 開發環境可選擇性跳過 CSRF 驗證 (不建議用於生產)
if settings.ENVIRONMENT == "development" and settings.DEBUG:
# 即使在開發環境也記錄警告
if not csrf_cookie or not x_csrf_token:
logger.warning(
"csrf_skipped_dev_mode",
has_cookie=bool(csrf_cookie),
has_header=bool(x_csrf_token),
path=request.url.path,
)
return "dev-mode-bypass"
# 生產環境: 嚴格驗證
if not csrf_cookie:
logger.warning(
"csrf_cookie_missing",
path=request.url.path,
client_ip=request.client.host if request.client else "unknown",
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token cookie missing. Please refresh the page.",
)
if not x_csrf_token:
logger.warning(
"csrf_header_missing",
path=request.url.path,
client_ip=request.client.host if request.client else "unknown",
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token header missing. Ensure X-CSRF-Token header is sent.",
)
# 時序安全比較 (防止 timing attack)
if not secrets.compare_digest(csrf_cookie, x_csrf_token):
logger.error(
"csrf_token_mismatch",
path=request.url.path,
client_ip=request.client.host if request.client else "unknown",
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token validation failed. Please refresh the page and try again.",
)
logger.debug(
"csrf_token_verified",
path=request.url.path,
)
return csrf_cookie
# =============================================================================
# Response Helper
# =============================================================================
def set_csrf_cookie(response: Response, token: str | None = None) -> str:
"""
在 Response 中設定 CSRF Cookie
Args:
response: FastAPI Response 物件
token: 指定的 Token (None 時自動生成)
Returns:
str: 設定的 CSRF Token
"""
csrf_token = token or generate_csrf_token()
response.set_cookie(
key=CSRF_COOKIE_NAME,
value=csrf_token,
httponly=False, # 前端 JS 需要讀取
secure=settings.ENVIRONMENT == "production", # 生產環境啟用 HTTPS Only
samesite="strict", # 嚴格同站策略
max_age=3600, # 1 小時有效
path="/",
)
logger.debug(
"csrf_cookie_set",
token_preview=csrf_token[:8] + "...",
)
return csrf_token
# =============================================================================
# Type Alias for Dependency Injection
# =============================================================================
CSRFToken = Annotated[str, Depends(verify_csrf_token)]

View File

@@ -33,6 +33,7 @@ from sentry_sdk.integrations.starlette import StarletteIntegration
from src.api.v1 import agents as agents_v1 # Phase 9.5: Agent Teams API
from src.api.v1 import ai as ai_v1
from src.api.v1 import approvals as approvals_v1
from src.api.v1 import csrf as csrf_v1 # Phase 20: CSRF Protection
from src.api.v1 import audit_logs as audit_logs_v1
from src.api.v1 import auto_repair as auto_repair_v1 # #8: 自動升級決策
from src.api.v1 import dashboard as dashboard_v1
@@ -370,6 +371,7 @@ async def global_exception_handler(_request: Request, exc: Exception) -> JSONRes
# New v1 API routes
app.include_router(health_v1.router, prefix="/api/v1", tags=["Health"])
app.include_router(csrf_v1.router, prefix="/api/v1", tags=["Security"]) # Phase 20
app.include_router(dashboard_v1.router, prefix="/api/v1", tags=["Dashboard"])
app.include_router(approvals_v1.router, prefix="/api/v1", tags=["HITL Approvals"])
app.include_router(ai_v1.router, prefix="/api/v1", tags=["AI Decision"])

View File

@@ -0,0 +1,39 @@
"""
CSRF Models (Phase 20)
======================
CSRF Token 相關的 Pydantic 模型。
建立日期: 2026-03-28 (台北時間)
建立者: Claude Code (首席架構師)
"""
from pydantic import BaseModel, Field
class CSRFTokenResponse(BaseModel):
"""CSRF Token 回應模型"""
token: str = Field(
...,
description="CSRF Token (64 字元十六進位)",
min_length=64,
max_length=64,
)
cookie_name: str = Field(
default="awoooi_csrf_token",
description="Cookie 名稱",
)
header_name: str = Field(
default="X-CSRF-Token",
description="Header 名稱 (敏感請求時使用)",
)
model_config = {
"json_schema_extra": {
"example": {
"token": "a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd",
"cookie_name": "awoooi_csrf_token",
"header_name": "X-CSRF-Token",
}
}
}