fix: P0+P1 security and quality fixes
P0-1: Token refresh race condition — reject all pending requests on refresh failure
P0-2: Remove X-Forwarded-For trust in rate limiting — use only ConnectInfo IP
P1-3: Template grid reactive — use useSaaSStore() hook instead of getState()
P1-4: Agent Template detail modal — show emoji, personality, soul_content, welcome_message,
communication_style, source_id, scenarios, version
P1-5: adminRouting parse validation — type-safe llm_routing extraction from localStorage
P1-6: Remove unused @ant-design/charts dependency
P1-extra: Type addKeyMutation data parameter (replace any)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,6 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/charts": "^2.6.7",
|
||||
"@ant-design/icons": "^6.1.1",
|
||||
"@ant-design/pro-components": "^2.8.10",
|
||||
"@ant-design/pro-layout": "^7.22.7",
|
||||
|
||||
1377
admin-v2/pnpm-lock.yaml
generated
1377
admin-v2/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -188,18 +188,38 @@ export default function AgentTemplates() {
|
||||
>
|
||||
{detailRecord && (
|
||||
<Descriptions column={2} bordered size="small">
|
||||
<Descriptions.Item label="图标">{detailRecord.emoji || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="名称">{detailRecord.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="分类">{detailRecord.category}</Descriptions.Item>
|
||||
<Descriptions.Item label="模型">{detailRecord.model || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="来源">{sourceLabels[detailRecord.source]}</Descriptions.Item>
|
||||
<Descriptions.Item label="可见性">{visibilityLabels[detailRecord.visibility]}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{statusLabels[detailRecord.status]}</Descriptions.Item>
|
||||
<Descriptions.Item label="版本">{detailRecord.version ?? detailRecord.current_version}</Descriptions.Item>
|
||||
<Descriptions.Item label="描述" span={2}>{detailRecord.description || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="人格预设">{detailRecord.personality || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="沟通风格">{detailRecord.communication_style || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="模板标识" span={2}>{detailRecord.source_id || '-'}</Descriptions.Item>
|
||||
{detailRecord.welcome_message && (
|
||||
<Descriptions.Item label="欢迎语" span={2}>{detailRecord.welcome_message}</Descriptions.Item>
|
||||
)}
|
||||
{detailRecord.scenarios && detailRecord.scenarios.length > 0 && (
|
||||
<Descriptions.Item label="使用场景" span={2}>
|
||||
{detailRecord.scenarios.map((s) => <Tag key={s}>{s}</Tag>)}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="系统提示词" span={2}>
|
||||
<div style={{ whiteSpace: 'pre-wrap', maxHeight: 200, overflow: 'auto' }}>
|
||||
{detailRecord.system_prompt || '-'}
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
{detailRecord.soul_content && (
|
||||
<Descriptions.Item label="SOUL.md 人格配置" span={2}>
|
||||
<div style={{ whiteSpace: 'pre-wrap', maxHeight: 200, overflow: 'auto' }}>
|
||||
{detailRecord.soul_content}
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="工具" span={2}>
|
||||
{detailRecord.tools?.map((t) => <Tag key={t}>{t}</Tag>) || '-'}
|
||||
</Descriptions.Item>
|
||||
|
||||
@@ -66,7 +66,7 @@ export default function Providers() {
|
||||
})
|
||||
|
||||
const addKeyMutation = useMutation({
|
||||
mutationFn: ({ providerId, data }: { providerId: string; data: any }) =>
|
||||
mutationFn: ({ providerId, data }: { providerId: string; data: { key_label: string; key_value: string; priority?: number; max_rpm?: number; max_tpm?: number } }) =>
|
||||
providerService.addKey(providerId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['provider-keys', keyModalProviderId] })
|
||||
|
||||
@@ -45,10 +45,18 @@ request.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
// ── 响应拦截器:401 自动刷新 ──────────────────────────────
|
||||
|
||||
let isRefreshing = false
|
||||
let pendingRequests: Array<(token: string) => void> = []
|
||||
let pendingRequests: Array<{
|
||||
resolve: (token: string) => void
|
||||
reject: (error: unknown) => void
|
||||
}> = []
|
||||
|
||||
function onTokenRefreshed(newToken: string) {
|
||||
pendingRequests.forEach((cb) => cb(newToken))
|
||||
pendingRequests.forEach(({ resolve }) => resolve(newToken))
|
||||
pendingRequests = []
|
||||
}
|
||||
|
||||
function onTokenRefreshFailed(error: unknown) {
|
||||
pendingRequests.forEach(({ reject }) => reject(error))
|
||||
pendingRequests = []
|
||||
}
|
||||
|
||||
@@ -67,10 +75,13 @@ request.interceptors.response.use(
|
||||
}
|
||||
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve) => {
|
||||
pendingRequests.push((newToken: string) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingRequests.push({
|
||||
resolve: (newToken: string) => {
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
resolve(request(originalRequest))
|
||||
},
|
||||
reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -93,10 +104,12 @@ request.interceptors.response.use(
|
||||
onTokenRefreshed(newToken)
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
return request(originalRequest)
|
||||
} catch {
|
||||
} catch (refreshError) {
|
||||
// 关键修复:刷新失败时 reject 所有等待中的请求,避免它们永远 hang
|
||||
onTokenRefreshFailed(refreshError)
|
||||
store.logout()
|
||||
window.location.href = '/login'
|
||||
return Promise.reject(error)
|
||||
return Promise.reject(refreshError)
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
@@ -112,7 +125,14 @@ request.interceptors.response.use(
|
||||
return Promise.reject(new ApiRequestError(error.response.status, body))
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
// 网络错误统一包装为 ApiRequestError
|
||||
return Promise.reject(
|
||||
new ApiRequestError(0, {
|
||||
error: 'network_error',
|
||||
message: error.message || '网络连接失败,请检查网络后重试',
|
||||
status: 0,
|
||||
})
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -129,18 +129,13 @@ pub async fn public_rate_limit_middleware(
|
||||
"public_rate_limit", "请求频率超限,请稍后再试")
|
||||
};
|
||||
|
||||
// 从连接信息或 header 提取客户端 IP
|
||||
// 从连接信息提取客户端 IP
|
||||
// 安全策略: 仅使用 TCP 连接层 IP,不信任 X-Forwarded-For / X-Real-IP 头
|
||||
// 反向代理场景下应使用 ConnectInfo<SocketAddr> 或在代理层做限流
|
||||
let client_ip = req.extensions()
|
||||
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
||||
.map(|ci| ci.0.ip().to_string())
|
||||
.unwrap_or_else(|| {
|
||||
req.headers()
|
||||
.get("x-real-ip")
|
||||
.or_else(|| req.headers().get("x-forwarded-for"))
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.split(',').next().unwrap_or("unknown").trim().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
});
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
let key = format!("{}:{}", key_prefix, client_ip);
|
||||
let now = Instant::now();
|
||||
|
||||
@@ -355,12 +355,13 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
|
||||
// This allows admins to force all clients to use relay or local mode.
|
||||
let adminForceLocal = false;
|
||||
try {
|
||||
const storedAccount = JSON.parse(localStorage.getItem('zclaw-saas-account') || '{}');
|
||||
const raw = localStorage.getItem('zclaw-saas-account');
|
||||
if (raw) {
|
||||
const storedAccount = JSON.parse(raw);
|
||||
// 类型安全解析: 仅接受 'relay' | 'local' 两个合法值
|
||||
const adminRouting = storedAccount?.account?.llm_routing;
|
||||
|
||||
if (adminRouting === 'relay') {
|
||||
// Force SaaS Relay mode — admin override
|
||||
// Set connection mode to 'saas' so the SaaS relay section below activates
|
||||
localStorage.setItem('zclaw-connection-mode', 'saas');
|
||||
log.debug('Admin llm_routing=relay: forcing SaaS relay mode');
|
||||
} else if (adminRouting === 'local' && isTauriRuntime()) {
|
||||
@@ -369,6 +370,8 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
|
||||
localStorage.setItem('zclaw-connection-mode', 'tauri');
|
||||
log.debug('Admin llm_routing=local: forcing local Kernel mode');
|
||||
}
|
||||
// 其他值(含 undefined/null/非法值)忽略,走默认逻辑
|
||||
}
|
||||
} catch { /* ignore parse errors, fall through to default logic */ }
|
||||
|
||||
// === Internal Kernel Mode: Admin forced local ===
|
||||
|
||||
Reference in New Issue
Block a user