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

@@ -1,9 +1,10 @@
// ============================================================
// ZCLAW Admin V2 — Axios 实例 + JWT 拦截器
// ZCLAW Admin V2 — Axios 实例 + 认证拦截器
// ============================================================
//
// 认证策略: 主路径使用 HttpOnly cookie浏览器自动附加
// Authorization header 作为 fallback 保留用于 API 客户端
// 认证策略: HttpOnly cookie浏览器自动附加到同域请求)。
// 所有 token 均通过 cookie 传递,前端 JS 无法读取
// withCredentials: true 确保浏览器发送 HttpOnly cookie。
import axios from 'axios'
import type { AxiosError, InternalAxiosRequestConfig } from 'axios'
@@ -32,26 +33,16 @@ const request = axios.create({
withCredentials: true, // 发送 HttpOnly cookies
})
// ── 请求拦截器:附加 Authorization header fallback ──────────
request.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = useAuthStore.getState().token
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// ── 响应拦截器401 自动刷新 ──────────────────────────────
// ── 响应拦截器:401 自动刷新 cookie ──────────────────────
let isRefreshing = false
let pendingRequests: Array<{
resolve: (token: string) => void
resolve: (value: unknown) => void
reject: (error: unknown) => void
}> = []
function onTokenRefreshed(newToken: string) {
pendingRequests.forEach(({ resolve }) => resolve(newToken))
function onTokenRefreshed() {
pendingRequests.forEach(({ resolve }) => resolve(undefined))
pendingRequests = []
}
@@ -65,10 +56,10 @@ request.interceptors.response.use(
async (error: AxiosError<{ error?: string; message?: string }>) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
// 401 尝试刷新 Token
// 401 -> 尝试刷新 cookie
if (error.response?.status === 401 && !originalRequest._retry) {
const store = useAuthStore.getState()
if (!store.refreshToken) {
if (!store.isAuthenticated) {
store.logout()
window.location.href = '/login'
return Promise.reject(error)
@@ -77,10 +68,7 @@ request.interceptors.response.use(
if (isRefreshing) {
return new Promise((resolve, reject) => {
pendingRequests.push({
resolve: (newToken: string) => {
originalRequest.headers.Authorization = `Bearer ${newToken}`
resolve(request(originalRequest))
},
resolve: () => resolve(request(originalRequest)),
reject,
})
})
@@ -90,22 +78,15 @@ request.interceptors.response.use(
isRefreshing = true
try {
const res = await axios.post(`${BASE_URL}/auth/refresh`, null, {
headers: { Authorization: `Bearer ${store.refreshToken}` },
withCredentials: true, // 发送 refresh cookie
// Refresh endpoint uses HttpOnly cookie (sent automatically via withCredentials)
await axios.post(`${BASE_URL}/auth/refresh`, null, {
withCredentials: true,
})
const newToken = res.data.token as string
const newRefreshToken = res.data.refresh_token as string
// 更新内存中的 token实际认证通过 HttpOnly cookie浏览器已自动更新
store.setToken(newToken)
if (newRefreshToken) {
store.setRefreshToken(newRefreshToken)
}
onTokenRefreshed(newToken)
originalRequest.headers.Authorization = `Bearer ${newToken}`
// Cookie is refreshed server-side; browser has the new cookie automatically
onTokenRefreshed()
return request(originalRequest)
} catch (refreshError) {
// 关键修复:刷新失败时 reject 所有等待中的请求,避免它们永远 hang
// Refresh failed — reject all pending requests to prevent hangs
onTokenRefreshFailed(refreshError)
store.logout()
window.location.href = '/login'