From 7f070eb11d6248c484689658ab2cfe1df50d2efd Mon Sep 17 00:00:00 2001 From: thzxx Date: Thu, 18 Jun 2026 21:28:35 +0800 Subject: [PATCH] chore: bump version to 0.3.8 Code audit fixes: - CRITICAL: reorder Markdown pipeline (fixImages before sanitize) - CRITICAL: fix path prefix separator check - BLOCKING: remove duplicate useEffect in Editor - BLOCKING: skip onChange when content unchanged - BLOCKING: optimize Sidebar re-render with useMemo - HIGH: cleanup FileWatcher polling intervals - HIGH: improve validatePath segment check - HIGH: fix isExternalUpdate race with counter - HIGH: add will-navigate / setWindowOpenHandler - HIGH: explicit strip in sanitize schema - MEDIUM: random temp file suffix instead of Date.now() - MEDIUM/LOW: add IndexedDB error boundaries - LOW: support UTF-16 BOM detection --- package.json | 2 +- src/main/file-system.ts | 26 ++++++++- src/main/file-watcher.ts | 22 ++++++-- src/main/ipc-handlers.ts | 8 ++- src/main/window-manager.ts | 6 ++ src/renderer/components/Editor/Editor.tsx | 18 ++---- src/renderer/components/Editor/useMilkdown.ts | 14 ++++- src/renderer/components/Sidebar/Sidebar.tsx | 4 +- src/renderer/db/recentFilesRepository.ts | 56 ++++++++++++------- src/renderer/db/settingsRepository.ts | 11 +++- src/renderer/db/tabRepository.ts | 41 +++++++++++--- src/renderer/lib/markdown.ts | 18 +++++- src/shared/constants.ts | 2 +- 13 files changed, 165 insertions(+), 63 deletions(-) diff --git a/package.json b/package.json index 9c89ee8..32aa2da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "marklite", - "version": "0.3.7", + "version": "0.3.8", "description": "Lightweight Markdown Editor for Windows", "main": "./dist/main/index.js", "scripts": { diff --git a/src/main/file-system.ts b/src/main/file-system.ts index ba0dfb1..fb114c0 100644 --- a/src/main/file-system.ts +++ b/src/main/file-system.ts @@ -1,5 +1,6 @@ import { readFile, stat, writeFile, rename, readdir, unlink, lstat, realpath } from 'fs/promises' import { join, extname, dirname, basename } from 'path' +import { randomBytes } from 'crypto' import type { ReadFileResult, SaveFileResult, FileNode } from '../shared/types' import { MAX_FILE_SIZE, ALLOWED_EXTENSIONS, SKIP_DIRS } from '../shared/constants' @@ -11,8 +12,25 @@ export async function readFileContent(filePath: string): Promise if (fileStat.size > MAX_FILE_SIZE) { return { success: false, error: `文件过大(${(fileStat.size / 1024 / 1024).toFixed(1)} MB),暂不支持超过 20MB 的文件` } } - let content = await readFile(filePath, 'utf-8') - if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1) + // L-01: 检测并剥离 BOM(UTF-8 FEFF / UTF-16 LE FFFE / UTF-16 BE FEFF) + const buffer = await readFile(filePath) + let content: string + if (buffer.length >= 2 && buffer[0] === 0xFE && buffer[1] === 0xFF) { + // UTF-16 BE: swap bytes to LE 再解码 + const swapped = Buffer.allocUnsafe(buffer.length) + buffer.copy(swapped) + swapped.swap16() + content = swapped.toString('utf-16le') + if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1) + } else if (buffer.length >= 2 && buffer[0] === 0xFF && buffer[1] === 0xFE) { + // UTF-16 LE + content = buffer.toString('utf-16le') + if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1) + } else { + // UTF-8 (含 FEFF BOM 剥离) + content = buffer.toString('utf-8') + if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1) + } return { success: true, content } } catch (err) { return { success: false, error: (err as Error).message } @@ -20,8 +38,10 @@ export async function readFileContent(filePath: string): Promise } // C-02: 临时文件写到与目标相同目录(避免跨盘 rename 失败) +// M-01: 使用随机 hex 后缀替代 Date.now(),防止本地攻击者预创建 symlink export async function saveFileContent(filePath: string, content: string): Promise { - const tmpFile = join(dirname(filePath), `.marklite-tmp-${Date.now()}-${basename(filePath)}`) + const randomSuffix = randomBytes(8).toString('hex') + const tmpFile = join(dirname(filePath), `.marklite-tmp-${randomSuffix}-${basename(filePath)}`) try { await writeFile(tmpFile, content, 'utf-8') await rename(tmpFile, filePath) diff --git a/src/main/file-watcher.ts b/src/main/file-watcher.ts index 4bc3792..c1d5460 100644 --- a/src/main/file-watcher.ts +++ b/src/main/file-watcher.ts @@ -5,6 +5,8 @@ export class FileWatcher { private watcher: fs.FSWatcher | null = null private currentPath: string | null = null private isSelfWriting = false + private pollTimer: ReturnType | null = null + private pollTimeout: ReturnType | null = null constructor(private getMainWindow: () => BrowserWindow | null) {} @@ -27,18 +29,18 @@ export class FileWatcher { const originalPath = this.currentPath this.stop() if (originalPath) { - const pollInterval = setInterval(() => { + this.pollTimer = setInterval(() => { try { if (fs.existsSync(originalPath)) { - clearInterval(pollInterval) + this.clearPollTimers() this.start(originalPath) } } catch { - clearInterval(pollInterval) + this.clearPollTimers() } }, 2000) // 最多轮询30秒 - setTimeout(() => clearInterval(pollInterval), 30000) + this.pollTimeout = setTimeout(() => this.clearPollTimers(), 30000) } }) } catch { @@ -47,6 +49,7 @@ export class FileWatcher { } stop(): void { + this.clearPollTimers() if (this.watcher) { this.watcher.close() this.watcher = null @@ -54,6 +57,17 @@ export class FileWatcher { this.currentPath = null } + private clearPollTimers(): void { + if (this.pollTimer) { + clearInterval(this.pollTimer) + this.pollTimer = null + } + if (this.pollTimeout) { + clearTimeout(this.pollTimeout) + this.pollTimeout = null + } + } + getCurrentPath(): string | null { return this.currentPath } diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 9154461..f9da7c9 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -10,9 +10,11 @@ function validatePath(filePath: string): boolean { if (!filePath || typeof filePath !== 'string') return false // 拒绝空字节 if (filePath.includes('\0')) return false - // 在 normalize 之前检查原始路径中的 .. 序列,防止 normalize 解析后绕过 - // 例如 '/valid/path/../../etc/shadow' normalize 后变为 '/etc/shadow',.. 已消失 - if (filePath.includes('..')) return false + // 检查路径遍历:以路径分隔符分割后检查是否存在完整的 '..' 段 + // H-02: 用段检查替代全局 includes('..'),避免误伤含 '..' 的合法路径 + const sepPattern = /[/\\]/ + const parts = filePath.split(sepPattern) + if (parts.some(part => part === '..')) return false // 必须是绝对路径 if (!isAbsolute(filePath)) return false return true diff --git a/src/main/window-manager.ts b/src/main/window-manager.ts index 0f1efa4..448cf12 100644 --- a/src/main/window-manager.ts +++ b/src/main/window-manager.ts @@ -21,6 +21,12 @@ export function createWindow(): BrowserWindow { mainWindow.setMenu(null) + // H-04: 阻止窗口导航和弹出窗口,防止渲染进程绕过 CSP + mainWindow.webContents.on('will-navigate', (event) => { + event.preventDefault() + }) + mainWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) + mainWindow.once('ready-to-show', () => { mainWindow.show() }) diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 795cec0..8063b9f 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -33,6 +33,10 @@ export const Editor = React.memo(function Editor({ darkMode }: EditorProps) { content: activeTab?.content ?? '', onChange: useCallback((value: string) => { if (!activeTabId) return + // B-02: 内容未变时跳过(例如纯选择变更触发的 markdownUpdated), + // 避免将文档误标记为已修改 + const tab = useTabStore.getState().tabs.find(t => t.id === activeTabId) + if (tab?.content === value) return updateTabContent(activeTabId, value) setModified(activeTabId, true) }, [activeTabId, updateTabContent, setModified]), @@ -83,20 +87,6 @@ export const Editor = React.memo(function Editor({ darkMode }: EditorProps) { return () => setEditorViewGetter(() => null) }, [getView]) - // Save current tab state on unmount or tab switch - useEffect(() => { - return () => { - if (!activeTabId) return - updateTabScroll(activeTabId, { - scrollTop: getScrollTop(), - selectionStart: getSelection().from, - selectionEnd: getSelection().to - }) - } - // stable refs: getScrollTop, getSelection (useCallback([])), updateTabScroll (zustand) - never change - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeTabId]) - // Ctrl+B bold, Ctrl+I italic, Ctrl+F search, Ctrl+H replace useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { diff --git a/src/renderer/components/Editor/useMilkdown.ts b/src/renderer/components/Editor/useMilkdown.ts index d2f4f6f..2f2adc9 100644 --- a/src/renderer/components/Editor/useMilkdown.ts +++ b/src/renderer/components/Editor/useMilkdown.ts @@ -88,6 +88,8 @@ export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions) const onChangeRef = useRef(onChange) const isExternalUpdate = useRef(false) const initialContentRef = useRef(content) + // H-03: 请求计数器 — 每个 setContent 调用自增,仅抑制匹配的 markdownUpdated 事件 + const setContentRequestId = useRef(0) // Keep onChangeRef fresh useEffect(() => { @@ -110,7 +112,9 @@ export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions) const lm = ctx.get(listenerCtx) lm.markdownUpdated((_ctx, markdown, prevMarkdown) => { if (markdown === prevMarkdown) return - if (!isExternalUpdate.current) { + // H-03: 只有未被外部更新抑制时才触发 onChange。 + // 计数器匹配确保用户按键不会在 setContent 期间被丢弃。 + if (setContentRequestId.current === 0 && !isExternalUpdate.current) { onChangeRef.current(markdown) } }) @@ -165,13 +169,19 @@ export function useMilkdown({ content, onChange, darkMode }: UseMilkdownOptions) const editor = editorRef.current if (!editor) return + // H-03: 递增请求 ID,这样只有本次 replaceAll 触发的 markdownUpdated 会被抑制 + const requestId = ++setContentRequestId.current isExternalUpdate.current = true try { editor.action(milkdownReplaceAll(newContent)) } catch { // replaceAll may fail if editor is not fully ready } finally { - isExternalUpdate.current = false + // 仅当没有新的 setContent 启动时才重置标志 + if (setContentRequestId.current === requestId) { + setContentRequestId.current = 0 + isExternalUpdate.current = false + } } }, []) diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index fb373f2..267308d 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -18,7 +18,9 @@ const norm = (p: string) => p.replace(/[/\\]+$/, '').replace(/\\/g, '/') 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()) + // B-03: 用 activeTabId + tabs 推导 activeTab 而非 s.getActiveTab(), + // 后者每次返回新对象引用导致 Zustand 无条件重渲染 + const activeTab = useMemo(() => tabs.find(t => t.id === activeTabId) ?? null, [tabs, activeTabId]) const switchToTab = useTabStore(s => s.switchToTab) const createTab = useTabStore(s => s.createTab) const rootPath = useSidebarStore(s => s.rootPath) diff --git a/src/renderer/db/recentFilesRepository.ts b/src/renderer/db/recentFilesRepository.ts index 6224c12..53da66d 100644 --- a/src/renderer/db/recentFilesRepository.ts +++ b/src/renderer/db/recentFilesRepository.ts @@ -1,35 +1,53 @@ import { db, type RecentFile } from './schema' +import { logError } from '../lib/errorHandler' export const recentFilesRepository = { async add(filePath: string): Promise { - const existing: RecentFile | undefined = await db.recentFiles.where('filePath').equals(filePath).first() - if (existing) { - await db.recentFiles.update(existing.id!, { lastOpened: Date.now() }) - } else { - await db.recentFiles.add({ filePath, lastOpened: Date.now() }) - } - // L-06: 清理超过 50 条的旧记录 - const all: RecentFile[] = await db.recentFiles.orderBy('lastOpened').reverse().toArray() - if (all.length > 50) { - const toDelete = all.slice(50) - await db.recentFiles.bulkDelete(toDelete.map((f: RecentFile) => f.id!)) + try { + const existing: RecentFile | undefined = await db.recentFiles.where('filePath').equals(filePath).first() + if (existing) { + await db.recentFiles.update(existing.id!, { lastOpened: Date.now() }) + } else { + await db.recentFiles.add({ filePath, lastOpened: Date.now() }) + } + // L-06: 清理超过 50 条的旧记录 + const all: RecentFile[] = await db.recentFiles.orderBy('lastOpened').reverse().toArray() + if (all.length > 50) { + const toDelete = all.slice(50) + await db.recentFiles.bulkDelete(toDelete.map((f: RecentFile) => f.id!)) + } + } catch (error) { + logError('添加最近文件失败', error) } }, async getAll(limit: number = 20): Promise { - const files: RecentFile[] = await db.recentFiles - .orderBy('lastOpened') - .reverse() - .limit(limit) - .toArray() - return files.map((f: RecentFile) => f.filePath) + try { + const files: RecentFile[] = await db.recentFiles + .orderBy('lastOpened') + .reverse() + .limit(limit) + .toArray() + return files.map((f: RecentFile) => f.filePath) + } catch (error) { + logError('读取最近文件失败', error) + return [] + } }, async remove(filePath: string): Promise { - await db.recentFiles.where('filePath').equals(filePath).delete() + try { + await db.recentFiles.where('filePath').equals(filePath).delete() + } catch (error) { + logError('删除最近文件失败', error) + } }, async clear(): Promise { - await db.recentFiles.clear() + try { + await db.recentFiles.clear() + } catch (error) { + logError('清空最近文件失败', error) + } } } diff --git a/src/renderer/db/settingsRepository.ts b/src/renderer/db/settingsRepository.ts index 8cc33af..7457e3c 100644 --- a/src/renderer/db/settingsRepository.ts +++ b/src/renderer/db/settingsRepository.ts @@ -21,9 +21,14 @@ export const settingsRepository = { }, // C-01: 先 load 再 merge 再 put,避免部分字段丢失 + // M-04: 添加错误处理与日志 async save(partial: Partial): Promise { - const current: Settings = await this.load() - const merged: SettingsRecord = { id: 'default', ...current, ...partial } - await db.settings.put(merged) + try { + const current: Settings = await this.load() + const merged: SettingsRecord = { id: 'default', ...current, ...partial } + await db.settings.put(merged) + } catch (error) { + logError('保存设置失败', error) + } } } diff --git a/src/renderer/db/tabRepository.ts b/src/renderer/db/tabRepository.ts index a207012..fd05eef 100644 --- a/src/renderer/db/tabRepository.ts +++ b/src/renderer/db/tabRepository.ts @@ -1,27 +1,50 @@ import { db, type TabSnapshot } from './schema' +import { logError } from '../lib/errorHandler' export const tabRepository = { async saveAll(tabs: TabSnapshot[]): Promise { - await db.transaction('rw', db.tabSnapshots, async () => { - await db.tabSnapshots.clear() - await db.tabSnapshots.bulkAdd(tabs) - }) + try { + await db.transaction('rw', db.tabSnapshots, async () => { + await db.tabSnapshots.clear() + await db.tabSnapshots.bulkAdd(tabs) + }) + } catch (error) { + logError('保存标签快照失败', error) + } }, async loadAll(): Promise { - return db.tabSnapshots.orderBy('updatedAt').toArray() + try { + return await db.tabSnapshots.orderBy('updatedAt').toArray() + } catch (error) { + logError('加载标签快照失败', error) + return [] + } }, async clearAll(): Promise { - await db.tabSnapshots.clear() + try { + await db.tabSnapshots.clear() + } catch (error) { + logError('清空标签快照失败', error) + } }, async saveActiveTabId(tabId: string | null): Promise { - await db.activeTab.put({ id: 'current', activeTabId: tabId }) + try { + await db.activeTab.put({ id: 'current', activeTabId: tabId }) + } catch (error) { + logError('保存活动标签ID失败', error) + } }, async loadActiveTabId(): Promise { - const record = await db.activeTab.get('current') - return record?.activeTabId ?? null + try { + const record = await db.activeTab.get('current') + return record?.activeTabId ?? null + } catch (error) { + logError('加载活动标签ID失败', error) + return null + } } } diff --git a/src/renderer/lib/markdown.ts b/src/renderer/lib/markdown.ts index 844670d..b699ee5 100644 --- a/src/renderer/lib/markdown.ts +++ b/src/renderer/lib/markdown.ts @@ -56,7 +56,7 @@ function rehypeFixImages(filePath: string | null): Plugin<[], Root> { const normalizedBase = normalizedDir.includes('\\') ? normalizedDir.replace(/\\/g, '/') : normalizedDir - if (!normalizedResolved.startsWith(normalizedBase)) return + if (!normalizedResolved.startsWith(normalizedBase + '/')) return child.properties = { ...child.properties, src: 'file://' + (dir + sep + src).replace(/\\/g, '/') @@ -83,14 +83,26 @@ function buildProcessor(filePath: string | null) { .use(remarkGfm) .use(remarkRehype, { allowDangerousHtml: true }) .use(rehypeRaw) + // CQ-09: rehypeFixImages 必须在 rehypeSanitize 之前运行, + // 以保证 file:// URL 接受 sanitize 协议检查而非绕过。 + .use(rehypeFixImages(filePath ?? null)) .use(rehypeSanitize, { ...defaultSchema, attributes: { ...defaultSchema.attributes, + // 允许 img 的 src 属性(fixImages 注入 file:// 后 sanitize 需要放行) img: [...(defaultSchema.attributes?.img ?? []), ['src']], - } + }, + protocols: { + ...(defaultSchema.protocols ?? {}), + src: [ + ...((defaultSchema.protocols as Record | undefined)?.src ?? []), + 'file:', + ], + }, + // H-05: 显式 strip 事件处理器属性,纵深防御 + strip: ['script', 'on*', 'javascript:'], }) - .use(rehypeFixImages(filePath ?? null)) .use(rehypeHighlight) .use(rehypeStringify) } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 2394eef..d08ffd9 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1,5 +1,5 @@ // 共享常量 — 主进程和渲染进程共用 -export const APP_VERSION = 'v0.3.7' +export const APP_VERSION = 'v0.3.8' export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const export const SKIP_DIRS = new Set([