fix(security): implement all 15 security fixes from penetration test V1

Security audit (2026-03-31): 5 HIGH + 10 MEDIUM issues, all fixed.

HIGH:
- H1: JWT password_version mechanism (pwv in Claims, middleware verification,
  auto-increment on password change)
- H2: Docker saas port bound to 127.0.0.1
- H3: TOTP encryption key decoupled from JWT secret (production bailout)
- H4+H5: Tauri CSP hardened (removed unsafe-inline, restricted connect-src)

MEDIUM:
- M1: Persistent rate limiting (PostgreSQL rate_limit_events table)
- M2: Account lockout (5 failures -> 15min lock)
- M3: RFC 5322 email validation with regex
- M4: Device registration typed struct with length limits
- M5: Provider URL validation on create/update (SSRF prevention)
- M6: Legacy TOTP secret migration (fixed nonce -> random nonce)
- M7: Legacy frontend crypto migration (static salt -> random salt)
- M8+M9: Admin frontend: removed JS token storage, HttpOnly cookie only
- M10: Pipeline debug log sanitization (keys only, 100-char truncation)

Also: fixed CLAUDE.md Section 12 (was corrupted), added title.rs middleware
skeleton, fixed RegisterDeviceRequest visibility.
This commit is contained in:
iven
2026-04-01 08:38:37 +08:00
parent 3b1a017761
commit e3b93ff96d
26 changed files with 597 additions and 220 deletions

View File

@@ -3,8 +3,8 @@
// ============================================================
//
// 安全策略: JWT token 通过 HttpOnly cookie 传递,前端 JS 无法读取。
// account 信息(显示名/角色)存 localStorage 用于页面刷新后恢复 UI。
// 内存中的 token/refreshToken 仅用于 Authorization header fallbackAPI 客户端兼容)
// account 信息(显示名/角色)存 localStorage 用于页面刷新后恢复 UI。
// isAuthenticated 标记用于判断登录状态,不暴露任何 token 到 JS
import { create } from 'zustand'
import type { AccountPublic } from '@/types'
@@ -27,24 +27,23 @@ const ROLE_PERMISSIONS: Record<string, string[]> = {
const ACCOUNT_KEY = 'zclaw_admin_account'
/** 从 localStorage 恢复 account 信息token 通过 HttpOnly cookie 管理) */
function loadFromStorage(): { account: AccountPublic | null } {
function loadFromStorage(): { account: AccountPublic | null; isAuthenticated: boolean } {
const raw = localStorage.getItem(ACCOUNT_KEY)
let account: AccountPublic | null = null
if (raw) {
try { account = JSON.parse(raw) } catch { /* ignore */ }
}
return { account }
// If account exists in localStorage, mark as authenticated (cookie validation
// happens in AuthGuard via GET /auth/me — this is just a UI hint)
return { account, isAuthenticated: account !== null }
}
interface AuthState {
token: string | null
refreshToken: string | null
isAuthenticated: boolean
account: AccountPublic | null
permissions: string[]
setToken: (token: string) => void
setRefreshToken: (refreshToken: string) => void
login: (token: string, refreshToken: string, account: AccountPublic) => void
login: (account: AccountPublic) => void
logout: () => void
hasPermission: (permission: string) => boolean
}
@@ -56,26 +55,15 @@ export const useAuthStore = create<AuthState>((set, get) => {
: []
return {
token: null,
refreshToken: null,
isAuthenticated: stored.isAuthenticated,
account: stored.account,
permissions: perms,
setToken: (token: string) => {
set({ token })
},
setRefreshToken: (refreshToken: string) => {
set({ refreshToken })
},
login: (token: string, refreshToken: string, account: AccountPublic) => {
login: (account: AccountPublic) => {
// account 保留 localStorage仅用于 UI 显示,非敏感)
localStorage.setItem(ACCOUNT_KEY, JSON.stringify(account))
// token 仅存内存(实际认证通过 HttpOnly cookie
set({
token,
refreshToken,
isAuthenticated: true,
account,
permissions: ROLE_PERMISSIONS[account.role] ?? [],
})
@@ -83,7 +71,7 @@ export const useAuthStore = create<AuthState>((set, get) => {
logout: () => {
localStorage.removeItem(ACCOUNT_KEY)
set({ token: null, refreshToken: null, account: null, permissions: [] })
set({ isAuthenticated: false, account: null, permissions: [] })
// 调用后端 logout 清除 HttpOnly cookiesfire-and-forget
fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
},