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 @@
-
+
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 })
}