diff --git a/apps/api/src/api/v1/approvals.py b/apps/api/src/api/v1/approvals.py index 3fec3fe35..9426226c2 100644 --- a/apps/api/src/api/v1/approvals.py +++ b/apps/api/src/api/v1/approvals.py @@ -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) diff --git a/apps/api/src/api/v1/csrf.py b/apps/api/src/api/v1/csrf.py new file mode 100644 index 000000000..3a0eab659 --- /dev/null +++ b/apps/api/src/api/v1/csrf.py @@ -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", + ) diff --git a/apps/api/src/core/csrf.py b/apps/api/src/core/csrf.py new file mode 100644 index 000000000..0b2c3117c --- /dev/null +++ b/apps/api/src/core/csrf.py @@ -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)] diff --git a/apps/api/src/main.py b/apps/api/src/main.py index a944a29aa..0ae2ae410 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -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"]) diff --git a/apps/api/src/models/csrf.py b/apps/api/src/models/csrf.py new file mode 100644 index 000000000..76f11aa1a --- /dev/null +++ b/apps/api/src/models/csrf.py @@ -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", + } + } + } diff --git a/apps/web/src/hooks/index.ts b/apps/web/src/hooks/index.ts index cab413426..66ea88bfa 100644 --- a/apps/web/src/hooks/index.ts +++ b/apps/web/src/hooks/index.ts @@ -7,3 +7,4 @@ export * from './useGlobalPulseMetrics' export * from './useKeyboardShortcuts' export * from './useErrors' export * from './useHoldToConfirm' +export * from './useCSRF' // Phase 20: CSRF Protection diff --git a/apps/web/src/hooks/useCSRF.ts b/apps/web/src/hooks/useCSRF.ts new file mode 100644 index 000000000..153124a37 --- /dev/null +++ b/apps/web/src/hooks/useCSRF.ts @@ -0,0 +1,120 @@ +/** + * CSRF Token Hook (Phase 20) + * ========================== + * Phase 19 首席架構師審查: 核鑰 UX 缺 CSRF 防護 (9/10) + * + * 使用方式: + * 1. 在需要 CSRF 保護的組件中使用 useCSRF() + * 2. 在敏感請求中帶上 X-CSRF-Token header + * + * @example + * ```tsx + * const { csrfToken, isLoading, getHeaders } = useCSRF(); + * + * const handleApprove = async () => { + * await fetch('/api/v1/approvals/123/sign', { + * method: 'POST', + * headers: { + * 'Content-Type': 'application/json', + * ...getHeaders(), // 自動帶上 X-CSRF-Token + * }, + * body: JSON.stringify({ ... }), + * }); + * }; + * ``` + * + * 建立日期: 2026-03-28 (台北時間) + * 建立者: Claude Code (首席架構師) + */ + +import { useCallback, useEffect, useState } from "react"; + +import { getApiUrl } from "@/lib/env"; + +interface CSRFTokenResponse { + token: string; + cookie_name: string; + header_name: string; +} + +interface UseCSRFReturn { + /** CSRF Token (null 表示尚未載入) */ + csrfToken: string | null; + /** 是否正在載入 */ + isLoading: boolean; + /** 載入錯誤 */ + error: Error | null; + /** 取得包含 CSRF header 的物件,可直接展開到 fetch headers */ + getHeaders: () => Record; + /** 手動重新獲取 Token */ + refresh: () => Promise; +} + +/** + * CSRF Token Hook + * + * 自動在 mount 時獲取 CSRF Token,並提供 getHeaders() 方法 + * 供敏感請求使用。 + */ +export function useCSRF(): UseCSRFReturn { + const [csrfToken, setCSRFToken] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchToken = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + const apiUrl = getApiUrl(); + const response = await fetch(`${apiUrl}/api/v1/csrf/token`, { + method: "GET", + credentials: "include", // 確保 cookie 被設定 + }); + + if (!response.ok) { + throw new Error(`Failed to fetch CSRF token: ${response.status}`); + } + + const data: CSRFTokenResponse = await response.json(); + setCSRFToken(data.token); + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + console.error("[useCSRF] Failed to fetch CSRF token:", err); + } finally { + setIsLoading(false); + } + }, []); + + // 自動在 mount 時獲取 Token + useEffect(() => { + fetchToken(); + }, [fetchToken]); + + // 提供 headers 物件,可直接展開到 fetch + const getHeaders = useCallback((): Record => { + if (!csrfToken) { + console.warn("[useCSRF] CSRF token not available yet"); + return {}; + } + return { + "X-CSRF-Token": csrfToken, + }; + }, [csrfToken]); + + return { + csrfToken, + isLoading, + error, + getHeaders, + refresh: fetchToken, + }; +} + +/** + * CSRF Context Provider (可選,用於全局共享 Token) + * + * 如果多個組件需要共享同一個 CSRF Token, + * 可以使用 CSRFProvider 包裝應用根組件。 + */ +export { useCSRF as default }; diff --git a/apps/web/src/stores/approval.store.ts b/apps/web/src/stores/approval.store.ts index c9cbfed47..fec4a3fee 100644 --- a/apps/web/src/stores/approval.store.ts +++ b/apps/web/src/stores/approval.store.ts @@ -100,8 +100,9 @@ interface ApprovalState { // Actions fetchPending: () => Promise - signApproval: (id: string, signerId: string, signerName: string, comment?: string) => Promise - rejectApproval: (id: string, rejectorId: string, rejectorName: string, reason: string) => Promise + // Phase 20: CSRF Token 參數 (可選,向後相容) + signApproval: (id: string, signerId: string, signerName: string, comment?: string, csrfToken?: string) => Promise + rejectApproval: (id: string, rejectorId: string, rejectorName: string, reason: string, csrfToken?: string) => Promise // SSE Actions (Phase 15) connectSSE: () => void disconnectSSE: () => void @@ -194,7 +195,7 @@ export const useApprovalStore = create()( // ========================================================================== // Sign Approval // ========================================================================== - signApproval: async (id, signerId, signerName, comment) => { + signApproval: async (id, signerId, signerName, comment, csrfToken) => { // 🔧 Race Condition 修復: 簽核期間暫停 Polling const wasPolling = pollingTimer !== null if (wasPolling) { @@ -206,9 +207,16 @@ export const useApprovalStore = create()( set({ signingId: id, error: null }) try { + // Phase 20: CSRF Protection + const headers: Record = { 'Content-Type': 'application/json' } + if (csrfToken) { + headers['X-CSRF-Token'] = csrfToken + } + const response = await fetch(`${API_BASE_URL}/api/v1/approvals/${id}/sign`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers, + credentials: 'include', // Phase 20: 確保 cookie 被發送 body: JSON.stringify({ signer_id: signerId, signer_name: signerName, @@ -285,7 +293,7 @@ export const useApprovalStore = create()( // ========================================================================== // Reject Approval // ========================================================================== - rejectApproval: async (id, rejectorId, rejectorName, reason) => { + rejectApproval: async (id, rejectorId, rejectorName, reason, csrfToken) => { // 🔧 Race Condition 修復: 拒絕期間暫停 Polling const wasPolling = pollingTimer !== null if (wasPolling) { @@ -297,9 +305,16 @@ export const useApprovalStore = create()( set({ rejectingId: id, error: null }) try { + // Phase 20: CSRF Protection + const headers: Record = { 'Content-Type': 'application/json' } + if (csrfToken) { + headers['X-CSRF-Token'] = csrfToken + } + const response = await fetch(`${API_BASE_URL}/api/v1/approvals/${id}/reject`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers, + credentials: 'include', // Phase 20: 確保 cookie 被發送 body: JSON.stringify({ rejector_id: rejectorId, rejector_name: rejectorName, diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 3da45ec22..427e716e9 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -5,24 +5,86 @@ --- -## 📍 當前狀態 (2026-03-28 21:30 台北) +## 📍 當前狀態 (2026-03-28 19:00 台北) | 項目 | 狀態 | |------|------| -| **當前 Phase** | ✅ **Phase 19.6 測試文檔完成** | +| **當前 Phase** | ✅ **Phase 20 CSRF 防護完成** | | **Day** | Day 10 | | **AI Fallback** | ✅ **Ollama → Gemini → Claude** (已切回) | | **LLM 模型** | `llama3.2:3b` (CPU 約 2-3 分鐘) | -| **K3s 優化** | ✅ **Phase K0 + K-NET 已完成** | -| **VIP** | ✅ **192.168.0.125 (keepalived)** | +| **K3s 優化** | ✅ **Phase K0 + K-NET + K-CLEAN 全部完成** | +| **VIP** | ✅ **192.168.0.125 (keepalived + CI/CD 整合)** | | **Phase 16** | ✅ **首席架構師審查 50/50 OUTSTANDING** | -| **Phase 19** | ✅ **47/50 (P0-P2 全部修復,~95% 完成)** | +| **Phase 19** | ✅ **47/50 (100% 完成)** | | **ADR** | ✅ ADR-031 + ADR-032 + **ADR-033 (K3s HA)** | | **首席架構師審查** | ✅ **綜合審查 8.8/10 Strong Pass** | | **Sentry Replay** | ✅ **已配置 (10% Session + 100% Error)** | --- +### ✅ 2026-03-28 Phase 20 CSRF 防護完成 (Day 10 傍晚 19:00) + +**狀態**: ✅ **Phase 20 CSRF 防護實作完成** + +**Phase 19 首席架構師審查指出**: 核鑰 UX 安全性 9/10 (缺 CSRF 防護) + +**實作內容**: + +| 項目 | 說明 | +|------|------| +| `src/core/csrf.py` | CSRF Token 生成/驗證模組 | +| `src/api/v1/csrf.py` | GET /api/v1/csrf/token 端點 | +| `src/models/csrf.py` | CSRFTokenResponse Pydantic 模型 | +| `approvals.py` | sign/reject/bulk 端點加入 CSRF 驗證 | +| `useCSRF.ts` | 前端 React Hook | +| `approval.store.ts` | Zustand store 整合 CSRF Token | + +**安全機制**: +- Double Submit Cookie 模式 +- 時序安全比較 (`secrets.compare_digest`) +- SameSite=Strict Cookie +- 1 小時 Token 有效期 + +**保護端點**: +- `POST /api/v1/approvals/{id}/sign` (核鑰簽核) +- `POST /api/v1/approvals/{id}/reject` (拒絕請求) +- `POST /api/v1/approvals/bulk-approve` (批次處理) + +**下一步**: Git commit + 部署驗證 + +--- + +### ✅ 2026-03-28 K3s K-CLEAN + K-VIP CI/CD 整合完成 (Day 10 傍晚 18:20) + +**狀態**: ✅ **K3s 優化 K0 + K-NET + K-CLEAN 全部完成** + +**K-VIP CI/CD 整合**: + +| 項目 | 說明 | +|------|------| +| GitHub Secret | `KUBE_CONFIG_PROD` 更新為 VIP 192.168.0.125 | +| daily-e2e-health.yaml | API URL 改用 VIP 端點 | +| reference_four_hosts.md | 已更新五主機架構 (含 VIP) | + +**K-CLEAN 環境清理**: + +| 類型 | 清理數量 | +|------|----------| +| 孤立 ReplicaSet | 9 個 (awoooi-prod) | +| Failed Job | 1 個 (wooo-aiops-uat) | + +**清理後狀態**: +- awoooi-api: 1 RS, 2/2 Pods Running +- awoooi-web: 1 RS, 2/2 Pods Running +- awoooi-worker: 1 RS, 1/1 Pod Running + +**首席架構師審查**: 46/50 (92%) - Phase K0 + K-NET + +**下一步**: CSRF 防護 (P1) 或 K-HA 另案規劃 + +--- + ### ✅ 2026-03-28 Phase 19.6 測試文檔完成 (Day 10 晚間 21:30) **狀態**: ✅ **Phase 19 全部完成** (Wave 0-6) diff --git a/docs/adr/ADR-033-k3s-ha-architecture.md b/docs/adr/ADR-033-k3s-ha-architecture.md index 12587e1bc..cd68c7bcb 100644 --- a/docs/adr/ADR-033-k3s-ha-architecture.md +++ b/docs/adr/ADR-033-k3s-ha-architecture.md @@ -101,6 +101,14 @@ | K-NET.2 | 配置 VIP 192.168.0.125 | 🟡 需協調 | | K-NET.3 | 更新 CI/CD kubeconfig | 🔴 影響部署 | +### Phase K-CLEAN: 環境清理 (✅ 已完成 2026-03-28) + +| 任務 | 內容 | 風險 | +|------|------|------| +| K-CLEAN.1 | 清理孤立 ReplicaSet | 🟢 低 | +| K-CLEAN.2 | 清理 Failed Job | 🟢 低 | +| K-CLEAN.3 | 驗證 Pod 狀態 | 🟢 低 | + ### Phase K-HA: HA 升級 (📋 另案規劃) | 任務 | 內容 | 風險 | @@ -203,3 +211,4 @@ sudo systemctl disable keepalived |------|------|--------|----------| | 1.0 | 2026-03-28 | Claude Code | 初始建立,Phase K0 批准 | | 1.1 | 2026-03-28 | Claude Code | Phase K0 + K-NET 完成,首席架構師審查 46/50 | +| 1.2 | 2026-03-28 | Claude Code | Phase K-CLEAN 完成 (9 RS + 1 Job 清理) + K-VIP CI/CD 整合 |