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 会让前一步的输出
+ * 成为后一步的输入(`<` 二次解码为 `<`),单趟 + 表映射只解码
+ * 一层,语义与 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 会把 < 二次解码为 <;单趟解码保持一层语义
+ expect(stripHtml('<script>')).toBe('<script>')
+ expect(stripHtml('&')).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') + }) +})