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:
@@ -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)
|
||||
|
||||
62
apps/api/src/api/v1/csrf.py
Normal file
62
apps/api/src/api/v1/csrf.py
Normal 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
178
apps/api/src/core/csrf.py
Normal 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)]
|
||||
@@ -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"])
|
||||
|
||||
39
apps/api/src/models/csrf.py
Normal file
39
apps/api/src/models/csrf.py
Normal 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user