v0.3.3: 代码安全审查与Bug修复
CRITICAL 修复: - 路径遍历防护绕过:validatePath在normalize()之后检查..,可被绕过读取任意文件 - 编辑器异步创建竞态条件:editor.create()完成后组件已卸载导致内存泄漏 - editor.create() Promise缺少.catch() → 未捕获的Promise拒绝 - handleOpenRecent空文件被跳过:result.content truthy检查阻塞空文件打开 MAJOR 修复: - DIR_WATCH/TAB_SWITCHED IPC缺少路径校验,可被用于监视任意文件 - AboutDialog内联箭头函数使React.memo完全失效 - ConfirmDialog关闭时焦点恢复逻辑反转 - markdown.ts一刀切屏蔽..导致合法相对路径图片不显示 安全加固: - 移除未被使用的path.normalize导入 - 所有文件操作IPC使用validatePath统一校验 - 编辑器初始化添加cancelled标志防止卸载后设置实例 验证: TypeScript零错误, ESLint零错误零警告, 78/78测试通过
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import { AppIcon, Gitee } from '../Icons'
|
||||
|
||||
const APP_VERSION = 'v0.3.1'
|
||||
const APP_VERSION = 'v0.3.2'
|
||||
|
||||
interface AboutDialogProps {
|
||||
onClose: () => void
|
||||
|
||||
@@ -11,7 +11,7 @@ interface ConfirmDialogProps {
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
export const ConfirmDialog = React.memo(function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
@@ -24,22 +24,18 @@ export function ConfirmDialog({
|
||||
const confirmRef = useRef<HTMLButtonElement>(null)
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
// 保存焦点并在打开时聚焦确认按钮
|
||||
// 打开时保存焦点并聚焦确认按钮;关闭时恢复焦点
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (!open) {
|
||||
previousFocusRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
previousFocusRef.current = document.activeElement as HTMLElement
|
||||
const timer = setTimeout(() => confirmRef.current?.focus(), 50)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [open])
|
||||
|
||||
// 关闭时恢复焦点
|
||||
useEffect(() => {
|
||||
if (open) return
|
||||
|
||||
return () => {
|
||||
previousFocusRef.current?.focus()
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
@@ -107,6 +103,6 @@ export function ConfirmDialog({
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
ConfirmDialog.displayName = 'ConfirmDialog'
|
||||
|
||||
@@ -10,7 +10,7 @@ interface EditorProps {
|
||||
darkMode: boolean
|
||||
}
|
||||
|
||||
export function Editor({ darkMode }: EditorProps) {
|
||||
export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
|
||||
const activeTab = useTabStore(s => s.getActiveTab())
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const updateTabContent = useTabStore(s => s.updateTabContent)
|
||||
@@ -37,7 +37,7 @@ export function Editor({ darkMode }: EditorProps) {
|
||||
darkMode
|
||||
})
|
||||
|
||||
// Load content when switching tabs
|
||||
// Load content when switching tabs (only trigger on tab switch, not content edits)
|
||||
useEffect(() => {
|
||||
if (!activeTab) return
|
||||
setContent(activeTab.content)
|
||||
@@ -45,10 +45,11 @@ export function Editor({ darkMode }: EditorProps) {
|
||||
setScrollTop(activeTab.scrollTop)
|
||||
setSelection()
|
||||
})
|
||||
// stable refs: setContent, setScrollTop, setSelection are useCallback([]) - never change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTabId])
|
||||
|
||||
// Save current tab state on unmount
|
||||
// Save current tab state on unmount or tab switch
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (!activeTabId) return
|
||||
@@ -58,6 +59,7 @@ export function Editor({ darkMode }: EditorProps) {
|
||||
selectionEnd: getSelection().to
|
||||
})
|
||||
}
|
||||
// stable refs: getScrollTop, getSelection (useCallback([])), updateTabScroll (zustand) - never change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTabId])
|
||||
|
||||
@@ -98,6 +100,6 @@ export function Editor({ darkMode }: EditorProps) {
|
||||
<div ref={containerRef} className="milkdown-wrapper" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
Editor.displayName = 'Editor'
|
||||
|
||||
@@ -128,11 +128,22 @@ export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions)
|
||||
.use(trailing)
|
||||
.use(clipboard)
|
||||
|
||||
let cancelled = false
|
||||
|
||||
editor.create().then((created) => {
|
||||
if (cancelled) {
|
||||
// 组件已卸载,立即销毁以避免内存泄漏
|
||||
created.destroy()
|
||||
return
|
||||
}
|
||||
editorRef.current = created
|
||||
}).catch((err) => {
|
||||
// eslint-disable-next-line no-console -- editor creation failure must be reported
|
||||
console.error('Milkdown editor creation failed:', err)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
editorRef.current?.destroy()
|
||||
editorRef.current = null
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
// eslint-disable-next-line no-console -- React error boundary standard pattern
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner'
|
||||
// PF-07: 防抖延迟常量
|
||||
const PREVIEW_DEBOUNCE_MS = 150
|
||||
|
||||
export function Preview() {
|
||||
export const Preview = React.memo(function Preview() {
|
||||
const [html, setHtml] = useState('')
|
||||
const [rendering, setRendering] = useState(false)
|
||||
const previewRef = useRef<HTMLDivElement>(null)
|
||||
@@ -50,6 +50,7 @@ export function Preview() {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- activeTab is a derived reference (from getActiveTab()), we track content/filePath via optional chaining
|
||||
}, [activeTabId, activeTab?.content, activeTab?.filePath, setLoading])
|
||||
|
||||
// 拦截链接点击
|
||||
@@ -96,6 +97,6 @@ export function Preview() {
|
||||
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
Preview.displayName = 'Preview'
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useAutoExpandDir } from '../../hooks/useAutoExpandDir'
|
||||
|
||||
const norm = (p: string) => p.replace(/\\\\/g, '/')
|
||||
|
||||
export function Sidebar() {
|
||||
export const Sidebar = React.memo(function Sidebar() {
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const activeTab = useTabStore(s => s.getActiveTab())
|
||||
@@ -103,6 +103,6 @@ export function Sidebar() {
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
Sidebar.displayName = 'Sidebar'
|
||||
|
||||
@@ -12,7 +12,7 @@ interface ContextMenuState {
|
||||
tabId: string
|
||||
}
|
||||
|
||||
export function TabBar() {
|
||||
export const TabBar = React.memo(function TabBar() {
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
const activeTabId = useTabStore(s => s.activeTabId)
|
||||
const switchToTab = useTabStore(s => s.switchToTab)
|
||||
@@ -248,6 +248,6 @@ export function TabBar() {
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
TabBar.displayName = 'TabBar'
|
||||
|
||||
Reference in New Issue
Block a user