From efa7f61809aa4c1f3dc9aa5629b125db51b9031b Mon Sep 17 00:00:00 2001 From: thzxx Date: Thu, 4 Jun 2026 13:02:19 +0800 Subject: [PATCH] =?UTF-8?q?v0.3.3:=20=E4=BB=A3=E7=A0=81=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E4=B8=8EBug=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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测试通过 --- README.md | 2 +- eslint.config.js => eslint.config.mjs | 0 package.json | 2 +- src/main/index.ts | 1 + src/main/ipc-handlers.ts | 15 ++++++++----- src/renderer/App.tsx | 3 ++- .../components/AboutDialog/AboutDialog.tsx | 2 +- .../ConfirmDialog/ConfirmDialog.tsx | 20 +++++++----------- src/renderer/components/Editor/Editor.tsx | 10 +++++---- src/renderer/components/Editor/useMilkdown.ts | 11 ++++++++++ .../ErrorBoundary/ErrorBoundary.tsx | 1 + src/renderer/components/Preview/Preview.tsx | 5 +++-- src/renderer/components/Sidebar/Sidebar.tsx | 4 ++-- src/renderer/components/TabBar/TabBar.tsx | 4 ++-- src/renderer/hooks/useFileOperations.ts | 2 +- src/renderer/lib/errorHandler.ts | 1 + src/renderer/lib/markdown.ts | 21 ++++++++++++++++++- src/renderer/stores/tabStore.ts | 1 + 18 files changed, 72 insertions(+), 33 deletions(-) rename eslint.config.js => eslint.config.mjs (100%) diff --git a/README.md b/README.md index 8933237..0eec743 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ TypeScript React License - Version + Version

diff --git a/eslint.config.js b/eslint.config.mjs similarity index 100% rename from eslint.config.js rename to eslint.config.mjs diff --git a/package.json b/package.json index a4f0ae0..f07c37c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "marklite", - "version": "0.3.1", + "version": "0.3.2", "description": "Lightweight Markdown Editor for Windows", "main": "./dist/main/index.js", "scripts": { diff --git a/src/main/index.ts b/src/main/index.ts index ec94e21..95147fb 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -26,6 +26,7 @@ function openFileInTab(filePath: string): void { mainWindow.webContents.send('file:openInTab', { filePath, content: result.content }) } }).catch((err) => { + // eslint-disable-next-line no-console -- IPC file open error console.error('openFileInTab failed:', err) }) } diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 99dd48a..87955a9 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -3,18 +3,18 @@ import { readFileContent, saveFileContent, buildDirTree } from './file-system' import { FileWatcher, SidebarWatcher } from './file-watcher' import { IPC_CHANNELS } from '../shared/ipc-channels' import { stat } from 'fs/promises' -import { basename, normalize, isAbsolute } from 'path' +import { basename, isAbsolute } from 'path' // 安全校验:拒绝路径遍历攻击 function validatePath(filePath: string): boolean { if (!filePath || typeof filePath !== 'string') return false // 拒绝空字节 if (filePath.includes('\0')) return false - // 规范化路径后检查是否包含 .. - const normalized = normalize(filePath) - if (normalized.includes('..')) return false + // 在 normalize 之前检查原始路径中的 .. 序列,防止 normalize 解析后绕过 + // 例如 '/valid/path/../../etc/shadow' normalize 后变为 '/etc/shadow',.. 已消失 + if (filePath.includes('..')) return false // 必须是绝对路径 - if (!isAbsolute(normalized)) return false + if (!isAbsolute(filePath)) return false return true } @@ -185,6 +185,7 @@ export function registerIpcHandlers( // 目录监听 ipcMain.handle(IPC_CHANNELS.DIR_WATCH, (_event: IpcMainInvokeEvent, dirPath: string) => { + if (!validatePath(dirPath)) return sidebarWatcher.start(dirPath) }) @@ -195,6 +196,10 @@ export function registerIpcHandlers( // 标签切换 ipcMain.handle(IPC_CHANNELS.TAB_SWITCHED, (_event: IpcMainInvokeEvent, filePath: string | null) => { state.activeFilePath = filePath || null + if (filePath && !validatePath(filePath)) { + fileWatcher.stop() + return + } fileWatcher.start(filePath || '') const win = getMainWindow() if (win && !win.isDestroyed()) { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 86475d4..7d11795 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -41,6 +41,7 @@ export function App() { const { toasts, showToast, dismissToast, cleanup } = useToast() const { confirm, confirmDialogProps } = useConfirm() const [showAbout, setShowAbout] = useState(false) + const handleCloseAbout = useCallback(() => setShowAbout(false), []) useSettingsInit() useEffect(() => { loadFromDB(); return cleanup }, [loadFromDB, cleanup]) @@ -109,7 +110,7 @@ export function App() { - {showAbout && setShowAbout(false)} />} + {showAbout && } diff --git a/src/renderer/components/AboutDialog/AboutDialog.tsx b/src/renderer/components/AboutDialog/AboutDialog.tsx index 22ebdf0..98dd553 100644 --- a/src/renderer/components/AboutDialog/AboutDialog.tsx +++ b/src/renderer/components/AboutDialog/AboutDialog.tsx @@ -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 diff --git a/src/renderer/components/ConfirmDialog/ConfirmDialog.tsx b/src/renderer/components/ConfirmDialog/ConfirmDialog.tsx index 5adf7c6..ddb3739 100644 --- a/src/renderer/components/ConfirmDialog/ConfirmDialog.tsx +++ b/src/renderer/components/ConfirmDialog/ConfirmDialog.tsx @@ -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(null) const previousFocusRef = useRef(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({ ) -} +}) ConfirmDialog.displayName = 'ConfirmDialog' diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 1c2621d..32db05d 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -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) {

) -} +}) Editor.displayName = 'Editor' diff --git a/src/renderer/components/Editor/useMilkdown.ts b/src/renderer/components/Editor/useMilkdown.ts index 59dbc41..01a6a48 100644 --- a/src/renderer/components/Editor/useMilkdown.ts +++ b/src/renderer/components/Editor/useMilkdown.ts @@ -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 } diff --git a/src/renderer/components/ErrorBoundary/ErrorBoundary.tsx b/src/renderer/components/ErrorBoundary/ErrorBoundary.tsx index 31b19be..5200d73 100644 --- a/src/renderer/components/ErrorBoundary/ErrorBoundary.tsx +++ b/src/renderer/components/ErrorBoundary/ErrorBoundary.tsx @@ -21,6 +21,7 @@ export class ErrorBoundary extends Component { } 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) } diff --git a/src/renderer/components/Preview/Preview.tsx b/src/renderer/components/Preview/Preview.tsx index 18b6408..b07f01a 100644 --- a/src/renderer/components/Preview/Preview.tsx +++ b/src/renderer/components/Preview/Preview.tsx @@ -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(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() {
) -} +}) Preview.displayName = 'Preview' diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index 2f0e633..9c2d1b7 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -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() { /> ) -} +}) Sidebar.displayName = 'Sidebar' diff --git a/src/renderer/components/TabBar/TabBar.tsx b/src/renderer/components/TabBar/TabBar.tsx index 25f6968..c0dfbbf 100644 --- a/src/renderer/components/TabBar/TabBar.tsx +++ b/src/renderer/components/TabBar/TabBar.tsx @@ -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() { ) -} +}) TabBar.displayName = 'TabBar' diff --git a/src/renderer/hooks/useFileOperations.ts b/src/renderer/hooks/useFileOperations.ts index 7a90951..73ec21d 100644 --- a/src/renderer/hooks/useFileOperations.ts +++ b/src/renderer/hooks/useFileOperations.ts @@ -70,7 +70,7 @@ export function useFileOperations(showToast: (msg: string, type?: ToastType) => setLoading('file-open', true) try { const result = await window.electronAPI.readFile(filePath) - if (result.success && result.content) { + if (result.success) { createTab(filePath, result.content) recentFilesRepository.add(filePath) setTimeout(() => useTabStore.getState().saveToDB(), 100) diff --git a/src/renderer/lib/errorHandler.ts b/src/renderer/lib/errorHandler.ts index 6eca08d..254b8cb 100644 --- a/src/renderer/lib/errorHandler.ts +++ b/src/renderer/lib/errorHandler.ts @@ -17,6 +17,7 @@ export class AppError extends Error { /** 日志级别的错误处理(仅 console.error,不中断流程) */ export function logError(context: string, error: unknown): void { const message = error instanceof Error ? error.message : String(error) + // eslint-disable-next-line no-console -- intentional logging utility console.error(`[${context}] ${message}`, error) } diff --git a/src/renderer/lib/markdown.ts b/src/renderer/lib/markdown.ts index 13c6a86..1397626 100644 --- a/src/renderer/lib/markdown.ts +++ b/src/renderer/lib/markdown.ts @@ -8,6 +8,23 @@ import rehypeStringify from 'rehype-stringify' import rehypeHighlight from 'rehype-highlight' import type { Element, Root } from 'hast' +// 简单的路径解析(Electron renderer 中没有 path 模块) +function resolveRelativePath(base: string, rel: string): string { + const sep = base.includes('\\') ? '\\' : '/' + const parts = base.split(sep) + parts.pop() // 移除文件名 + const relParts = rel.split('/') + for (const p of relParts) { + if (p === '.' || p === '') continue + if (p === '..') { + if (parts.length > 0) parts.pop() + } else { + parts.push(p) + } + } + return parts.join(sep) +} + // 自定义 rehype 插件:将相对路径图片转为 file:// 绝对路径 function rehypeFixImages(filePath: string | null): Plugin<[], Root> { return () => (tree: Root) => { @@ -22,7 +39,9 @@ function rehypeFixImages(filePath: string | null): Plugin<[], Root> { if (child.type === 'element' && child.tagName === 'img') { const src = child.properties?.src as string | undefined if (src && !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:') && !src.startsWith('file://')) { - if (src.includes('..')) return + // 解析完整路径并检查是否越界到 markdown 文件所在目录之外 + const resolvedPath = resolveRelativePath(dir, src) + if (!resolvedPath.startsWith(dir + sep)) return child.properties = { ...child.properties, src: 'file://' + (dir + sep + src).replace(/\\/g, '/') diff --git a/src/renderer/stores/tabStore.ts b/src/renderer/stores/tabStore.ts index 5461b83..171bfaa 100644 --- a/src/renderer/stores/tabStore.ts +++ b/src/renderer/stores/tabStore.ts @@ -102,6 +102,7 @@ export const useTabStore = create((set, get) => { set({ _loaded: true }) } } catch (err) { + // eslint-disable-next-line no-console -- DB load error console.error('Failed to load tabs from DB:', err) set({ _loaded: true }) }