fix(security,data): P0 — 命令注入/停止毁数据/采集路径绕过/DB 清库/前端竞态
后端(实机 10.10.10.161 验证 host-binary 全链路 + 注入双路径 400): - Loki 版本号严格 ^\d+.\d+.\d+$ 校验并接入 Update 路径:version 拼进 SSH shell 命令, 旧校验放行 3.0.0'; id; echo ' 等注入载荷(root RCE) - Stop/Restart 清理清单移除 PVC:停止部署曾连带删除 loki-data PVC,filesystem 存储日志被永久清空(测试锁死红线) - 采集敏感路径防护覆盖默认 LogPath 分支与 collector Create(/etc/shadow 不再可绕过入库) - 数据文件损坏时原文件改名 .corrupt-* 留档再空库启动,不再被静默覆盖(loadFromDisk 抽出可测) 前端: - 审计页用户名过滤 300ms 防抖 + AbortController/版本号(逐键竞态数据错乱) - 向导自动部署改用创建返回实例(或 id 最大且 pending),不再盲取 list[0] 对错误实例发起真实部署 - 路由级 ErrorBoundary:单页崩溃/chunk 404 只影响当前页
This commit is contained in:
1 parent
5e59347c40
commit
2975452c21
17 files changed
+271
-68
No files matched your search
@@ -3,6 +3,7 @@ import { Routes, Route, Navigate, useLocation } from 'react-router'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import { isLoggedIn, getUser } from '@/lib/auth-storage'
|
||||
import { routes, findRouteMeta } from '@/router/routes'
|
||||
import ErrorBoundary from '@/components/ErrorBoundary'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const Layout = lazy(() => import('@/components/Layout'))
|
||||
@@ -65,7 +66,10 @@ const App = memo(function App() {
|
||||
path={route.path}
|
||||
element={
|
||||
route.auth ? (
|
||||
<RequireAuth>{<route.component />}</RequireAuth>
|
||||
<RequireAuth>
|
||||
{/* 页面级错误边界:单页崩溃/chunk 加载失败只影响当前页,侧栏与其它路由仍可用 */}
|
||||
<ErrorBoundary key={route.path}>{<route.component />}</ErrorBoundary>
|
||||
</RequireAuth>
|
||||
) : (
|
||||
<Suspense fallback={<AppLoadingFallback />}>
|
||||
<route.component />
|
||||
|
||||
@@ -88,12 +88,13 @@ export interface RestoreResult {
|
||||
|
||||
export const systemApi = {
|
||||
// 审计
|
||||
auditList: (params: AuditListParams = {}) =>
|
||||
auditList: (params: AuditListParams = {}, opts?: { signal?: AbortSignal }) =>
|
||||
http.get<{ list: AuditLog[]; total: number; page: number; pageSize: number }>(
|
||||
`/audit-logs${buildQuery(params as Record<string, unknown>)}`,
|
||||
opts,
|
||||
),
|
||||
auditGet: (id: number) => http.get<AuditLog>(`/audit-logs/${id}`),
|
||||
auditStats: () => http.get<AuditStats>('/audit-logs/stats'),
|
||||
auditStats: (opts?: { signal?: AbortSignal }) => http.get<AuditStats>('/audit-logs/stats', opts),
|
||||
|
||||
// 自检
|
||||
selfCheck: () => http.get<SelfCheckResult>('/system/self-check'),
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/** 防抖值:输入类过滤(如用户名逐键)先防抖再触发请求,避免每个键一组查询 */
|
||||
export function useDebouncedValue<T>(value: T, delayMs = 300): T {
|
||||
const [debounced, setDebounced] = useState(value)
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => setDebounced(value), delayMs)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [value, delayMs])
|
||||
return debounced
|
||||
}
|
||||
@@ -47,6 +47,7 @@ const wizard = {
|
||||
hint: 'Object storage (MinIO / RustFS / S3 / OSS) recommended; skip to use local disk.',
|
||||
},
|
||||
deploy: {
|
||||
autoTriggerFailed: 'Auto deploy failed; trigger it manually on the Deploy Loki page',
|
||||
meta: '{{mode}} · Namespace {{namespace}} · {{endpoint}}',
|
||||
endpointPending: 'endpoint available after deployment',
|
||||
openGrafana: 'Open log query ({url})',
|
||||
|
||||
@@ -48,6 +48,7 @@ const wizard = {
|
||||
hint: '推荐对象存储(MinIO / RustFS / S3 / OSS),更可靠;跳过则使用本地磁盘。',
|
||||
},
|
||||
deploy: {
|
||||
autoTriggerFailed: '自动触发部署失败,请到「部署 Loki」页手动触发',
|
||||
meta: '{{mode}} · 命名空间 {{namespace}} · {{endpoint}}',
|
||||
endpointPending: 'endpoint 待部署完成生成',
|
||||
openGrafana: '打开日志查询({{url}})',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useEffect, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { History, ShieldAlert, Search, RefreshCw, Eye } from 'lucide-react'
|
||||
import '@/i18n/domains'
|
||||
@@ -10,6 +10,7 @@ import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { ErrorState } from '@/components/ErrorState'
|
||||
import { PaginationBar } from '@/components/PaginationBar'
|
||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue'
|
||||
import { systemApi, type AuditLog, type AuditStats } from '@/api/system'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
@@ -176,27 +177,41 @@ export default function AuditPage() {
|
||||
const [module, setModule] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState(0)
|
||||
|
||||
// 乱序/重复防护:AbortController 取消上一轮 + 版本号丢弃过期响应;
|
||||
// username 是逐键输入,先防抖 300ms 再触发,避免每个键一组请求
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const versionRef = useRef(0)
|
||||
const load = useCallback(async () => {
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
const version = ++versionRef.current
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [res, st] = await Promise.all([
|
||||
systemApi.auditList({ page, pageSize: PAGE_SIZE, username, module, status: statusFilter }),
|
||||
systemApi.auditStats(),
|
||||
systemApi.auditList({ page, pageSize: PAGE_SIZE, username, module, status: statusFilter }, { signal: controller.signal }),
|
||||
systemApi.auditStats({ signal: controller.signal }),
|
||||
])
|
||||
if (version !== versionRef.current) return
|
||||
setList(Array.isArray(res.list) ? res.list : [])
|
||||
setTotal(res.total ?? 0)
|
||||
setStats(st ?? null)
|
||||
} catch (e) {
|
||||
if (version !== versionRef.current || (e as Error).name === 'AbortError') return
|
||||
setError(e instanceof Error ? e.message : t('loadFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (version === versionRef.current) setLoading(false)
|
||||
}
|
||||
}, [page, username, module, statusFilter, t])
|
||||
|
||||
const debouncedUsername = useDebouncedValue(username, 300)
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
return () => { abortRef.current?.abort() }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, debouncedUsername, module, statusFilter])
|
||||
|
||||
// 过滤条件变化时回到第一页,避免停在空白页
|
||||
useEffect(() => {
|
||||
|
||||
@@ -42,7 +42,7 @@ interface DeploymentFormDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** 创建成功回调(父级刷新列表并回到第 1 页) */
|
||||
onCreated: () => void
|
||||
onCreated: (created?: { id: number; name: string }) => void
|
||||
clusters: Cluster[]
|
||||
hosts: Host[]
|
||||
storages: StorageConfig[]
|
||||
@@ -98,7 +98,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await deploymentApi.create({
|
||||
const created = await deploymentApi.create({
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
version: values.version,
|
||||
@@ -117,7 +117,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
storageSize: values.storageSize,
|
||||
})
|
||||
onOpenChange(false)
|
||||
onCreated()
|
||||
onCreated(created)
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('form.errCreate'))
|
||||
} finally {
|
||||
|
||||
@@ -41,19 +41,26 @@ export const StepDeploy = memo(function StepDeploy({
|
||||
const [open, setOpen] = useState(false)
|
||||
const [deploying, setDeploying] = useState<number | null>(null)
|
||||
|
||||
const handleCreated = async () => {
|
||||
const handleCreated = async (created?: { id: number; name: string }) => {
|
||||
onChanged()
|
||||
// 创建成功即自动触发部署,向导内完成闭环
|
||||
try {
|
||||
const list = await deploymentApi.list({ page: 1, pageSize: 5 })
|
||||
const latest = list.list[0]
|
||||
if (latest && latest.status === 'pending') {
|
||||
setDeploying(latest.id)
|
||||
await deploymentApi.deploy(latest.id)
|
||||
toast.info(t('deploy.deployTriggered', { name: latest.name }))
|
||||
// 优先用创建接口返回的实例;拿不到时按「id 最大且 pending」匹配,
|
||||
// 绝不盲取 list[0] —— 并发创建/排序变化会对错误实例发起真实部署
|
||||
let target = created
|
||||
if (!target) {
|
||||
const list = await deploymentApi.list({ page: 1, pageSize: 5 })
|
||||
const pending = list.list.filter(item => item.status === 'pending')
|
||||
target = pending.sort((a, b) => b.id - a.id)[0]
|
||||
}
|
||||
} catch {
|
||||
// 列表/触发失败不打断向导,用户可到部署页手动触发
|
||||
if (target) {
|
||||
setDeploying(target.id)
|
||||
await deploymentApi.deploy(target.id)
|
||||
toast.info(t('deploy.deployTriggered', { name: target.name }))
|
||||
}
|
||||
} catch (e) {
|
||||
// 自动触发失败不打断向导,但要让用户知道需要去部署页手动触发
|
||||
toast.error(e instanceof Error ? e.message : t('deploy.autoTriggerFailed'))
|
||||
} finally {
|
||||
setDeploying(null)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user