diff --git a/src/main/services/backup-restore.js b/src/main/services/backup-restore.js index ff90dab..476491e 100644 --- a/src/main/services/backup-restore.js +++ b/src/main/services/backup-restore.js @@ -283,12 +283,15 @@ async function restorePartialLibrary(root, filePath, manifest) { const unzipped = await streamBackupZip(filePath, { coverWriter: async (name, data) => { const dest = path.join(coversDir, name) - const exists = await fs.access(dest).then( - () => true, - () => false, - ) - if (exists) return - await fs.writeFile(dest, data) + // CodeQL #15:access 预检 + writeFile 存在 check-then-write 竞态, + // 改用 wx 独占创建标志——已存在即 EEXIST 原子失败,语义与原 + // 「不覆盖既有封面」一致且无竞态窗口 + try { + await fs.writeFile(dest, data, { flag: 'wx' }) + } catch (err) { + if (errorCode(err) === 'EEXIST') return + throw err + } writtenCovers.push(dest) covers += 1 }, diff --git a/src/main/services/civitai.js b/src/main/services/civitai.js index b30ceba..8d45bfb 100644 --- a/src/main/services/civitai.js +++ b/src/main/services/civitai.js @@ -469,9 +469,23 @@ export async function fetchCivitaiModelType(modelId) { return { found: true, type: raw.type.trim(), tags } } +/** stripHtml 单次解码的常见实体表(命名 + 数字引用,键为原文形态) */ +const HTML_TEXT_ENTITIES = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + ''': "'", + ' ': ' ', +} + /** * 剥离 Civitai 描述中的 HTML 标签并压缩空白(描述为富文本 HTML, * 备注为纯文本;实体仅处理常见命名实体,其余由浏览器渲染时自然容错)。 + * CodeQL #2:实体解码为单趟替换——链式逐个 replace 会让前一步的输出 + * 成为后一步的输入(`&lt;` 二次解码为 `<`),单趟 + 表映射只解码 + * 一层,语义与 HTML 规范一致。输出仅流向纯文本 sink(Vue 插值/textarea/ + * CSV/Markdown 导出),无 HTML 渲染路径。 * 纯函数,便于单测。 * @param {string} html HTML 文本 * @returns {string} 纯文本(已修剪) @@ -482,12 +496,10 @@ export function stripHtml(html) { .replace(//gi, '\n') .replace(/<\/(p|div|li|h[1-6])>/gi, '\n') .replace(/<[^>]*>/g, '') - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/ /g, ' ') + .replace( + /&(?:amp|lt|gt|quot|#39|nbsp);/g, + (m) => HTML_TEXT_ENTITIES[m] ?? m, + ) .replace(/\n{3,}/g, '\n\n') .trim() } diff --git a/src/renderer/src/store/model-export.js b/src/renderer/src/store/model-export.js index 15b8590..f8bb325 100644 --- a/src/renderer/src/store/model-export.js +++ b/src/renderer/src/store/model-export.js @@ -122,10 +122,16 @@ function csvCell(value, numeric = false) { return /[",\r\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe } -/** Markdown 单元格转义(竖线/换行) */ +/** Markdown 单元格转义(反斜杠/竖线/换行) */ function mdCell(value) { const text = value === null || value === undefined ? '' : String(value) - return text.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ') + // CodeQL #12:先转义反斜杠再转义竖线——只转 | 时输入自带的 \ 会 + // 「吞掉」后续转义符(`a\` + `|` → `a\\|` 渲染为字面 \ 加分隔符 |, + // 造成表格单元格注入) + return text + .replace(/\\/g, '\\\\') + .replace(/\|/g, '\\|') + .replace(/\r?\n/g, ' ') } /** diff --git a/tests/civitai.test.js b/tests/civitai.test.js index d5ca1e8..db8396b 100644 --- a/tests/civitai.test.js +++ b/tests/civitai.test.js @@ -634,6 +634,12 @@ describe('stripHtml', () => { expect(stripHtml('bold text')).toBe('bold text') }) + it('实体只解码一层(CodeQL #2 单趟解码回归)', () => { + // 链式 replace 会把 &lt; 二次解码为 <;单趟解码保持一层语义 + expect(stripHtml('&lt;script&gt;')).toBe('<script>') + expect(stripHtml('&amp;')).toBe('&') + }) + it('连续空行压缩与非字符串容错', () => { expect(stripHtml('

a

b

')).not.toMatch( /\n{3,}/, diff --git a/tests/settingsBackup.test.js b/tests/settingsBackup.test.js index 0f3a658..e961a01 100644 --- a/tests/settingsBackup.test.js +++ b/tests/settingsBackup.test.js @@ -12,6 +12,7 @@ import { import { buildCsv, buildExportRows, + buildMarkdown, } from '../src/renderer/src/store/model-export' import { setupComponentTest } from './helpers/componentTest' @@ -512,3 +513,15 @@ describe('CSV 导出公式注入中和(O-4)', () => { expect(csv.split('\r\n')).toHaveLength(3) }) }) + +describe('Markdown 导出转义(CodeQL #12)', () => { + it('反斜杠先行转义:输入自带的 \\ 不会吞掉竖线转义符分裂单元格', () => { + // 输入 a\|b → 输出字面序列 a\\\|b(\\ 渲染为字面 \,\| 渲染为字面 |) + expect(buildMarkdown([csvRow({ name: 'a\\|b' })])).toContain('a\\\\\\|b') + }) + + it('普通竖线仍按 \\| 转义、换行折叠为空格', () => { + const md = buildMarkdown([csvRow({ note: 'x|y\nz' })]) + expect(md).toContain('x\\|y z') + }) +})