Files
zclaw_openfang/admin-v2/src/services/request.ts
iven e3b93ff96d 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.
2026-04-01 08:38:37 +08:00

129 lines
4.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ============================================================
// ZCLAW Admin V2 — Axios 实例 + 认证拦截器
// ============================================================
//
// 认证策略: HttpOnly cookie浏览器自动附加到同域请求
// 所有 token 均通过 cookie 传递,前端 JS 无法读取。
// withCredentials: true 确保浏览器发送 HttpOnly cookie。
import axios from 'axios'
import type { AxiosError, InternalAxiosRequestConfig } from 'axios'
import type { AxiosRequestConfig } from 'axios'
import type { ApiError } from '@/types'
import { useAuthStore } from '@/stores/authStore'
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1'
const TIMEOUT_MS = 30_000
/** API 业务错误 */
export class ApiRequestError extends Error {
constructor(
public status: number,
public body: ApiError,
) {
super(body.message || `Request failed with status ${status}`)
this.name = 'ApiRequestError'
}
}
const request = axios.create({
baseURL: BASE_URL,
timeout: TIMEOUT_MS,
headers: { 'Content-Type': 'application/json' },
withCredentials: true, // 发送 HttpOnly cookies
})
// ── 响应拦截器401 自动刷新 cookie ──────────────────────
let isRefreshing = false
let pendingRequests: Array<{
resolve: (value: unknown) => void
reject: (error: unknown) => void
}> = []
function onTokenRefreshed() {
pendingRequests.forEach(({ resolve }) => resolve(undefined))
pendingRequests = []
}
function onTokenRefreshFailed(error: unknown) {
pendingRequests.forEach(({ reject }) => reject(error))
pendingRequests = []
}
request.interceptors.response.use(
(response) => response,
async (error: AxiosError<{ error?: string; message?: string }>) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
// 401 -> 尝试刷新 cookie
if (error.response?.status === 401 && !originalRequest._retry) {
const store = useAuthStore.getState()
if (!store.isAuthenticated) {
store.logout()
window.location.href = '/login'
return Promise.reject(error)
}
if (isRefreshing) {
return new Promise((resolve, reject) => {
pendingRequests.push({
resolve: () => resolve(request(originalRequest)),
reject,
})
})
}
originalRequest._retry = true
isRefreshing = true
try {
// Refresh endpoint uses HttpOnly cookie (sent automatically via withCredentials)
await axios.post(`${BASE_URL}/auth/refresh`, null, {
withCredentials: true,
})
// Cookie is refreshed server-side; browser has the new cookie automatically
onTokenRefreshed()
return request(originalRequest)
} catch (refreshError) {
// Refresh failed — reject all pending requests to prevent hangs
onTokenRefreshFailed(refreshError)
store.logout()
window.location.href = '/login'
return Promise.reject(refreshError)
} finally {
isRefreshing = false
}
}
// 构造 ApiRequestError
if (error.response) {
const body: ApiError = {
error: error.response.data?.error || 'unknown',
message: error.response.data?.message || `请求失败 (${error.response.status})`,
status: error.response.status,
}
return Promise.reject(new ApiRequestError(error.response.status, body))
}
// 网络错误统一包装为 ApiRequestError
return Promise.reject(
new ApiRequestError(0, {
error: 'network_error',
message: error.message || '网络连接失败,请检查网络后重试',
status: 0,
})
)
},
)
export default request
/** 将 AbortSignal 注入 Axios config用于 TanStack Query 的请求取消 */
export function withSignal(config: AxiosRequestConfig = {}, signal?: AbortSignal): AxiosRequestConfig {
if (signal) {
return { ...config, signal }
}
return config
}