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:
@@ -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/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/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 align="center">
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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() {
|
||||
<StatusBar />
|
||||
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
|
||||
<DropOverlay />
|
||||
{showAbout && <AboutDialog onClose={() => setShowAbout(false)} />}
|
||||
{showAbout && <AboutDialog onClose={handleCloseAbout} />}
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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, '/')
|
||||
|
||||
@@ -102,6 +102,7 @@ export const useTabStore = create<TabState>((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 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user