From d504946de040dbe1d84a54905536ead8482934f8 Mon Sep 17 00:00:00 2001 From: Jin <22962980+JYC333@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:31:49 +0000 Subject: [PATCH] refactor: avoid xss attack --- .agents/migration_plan_autocomplete.md | 10 ++--- apps/client/src/services/note_autocomplete.ts | 43 +++++++++++++------ 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/.agents/migration_plan_autocomplete.md b/.agents/migration_plan_autocomplete.md index 36b90d094d..e45734898b 100644 --- a/.agents/migration_plan_autocomplete.md +++ b/.agents/migration_plan_autocomplete.md @@ -180,7 +180,7 @@ --- -### Step 6: 迁移"关闭弹窗"逻辑 + `attribute_detail.ts` 引用 +### Step 6: 迁移"关闭弹窗"逻辑 + `attribute_detail.ts` 引用 ✅ 完成 **文件变更:** - `apps/client/src/services/dialog.ts` — 替换 `$(".aa-input").autocomplete("close")` - `apps/client/src/components/entrypoints.ts` — 替换 `$(".aa-input").autocomplete("close")` @@ -188,12 +188,12 @@ - `apps/client/src/widgets/attribute_widgets/attribute_detail.ts` — 更新 `.algolia-autocomplete` 选择器 **说明:** -需要一个全局的"关闭所有 autocomplete"机制。方案:维护一个全局 `Set`,在各处调用时遍历关闭。可以放在 `note_autocomplete.ts` 中导出。 +引入了全局的 "关闭所有 headless autocomplete" 机制(通过 `closeAllHeadlessAutocompletes` 方法)。 **验证方式:** -- autocomplete 弹窗打开时切换标签页 → 弹窗自动关闭 -- autocomplete 弹窗打开时打开对话框 → 弹窗自动关闭 -- 点击 autocomplete 下拉菜单时属性面板不应关闭 +- ✅ autocomplete 弹窗打开时切换标签页 → 弹窗自动关闭 +- ✅ autocomplete 弹窗打开时打开对话框 → 弹窗自动关闭 +- ✅ 点击 autocomplete 下拉菜单时属性面板不应关闭 --- diff --git a/apps/client/src/services/note_autocomplete.ts b/apps/client/src/services/note_autocomplete.ts index 63a18a0952..63cbaa68dc 100644 --- a/apps/client/src/services/note_autocomplete.ts +++ b/apps/client/src/services/note_autocomplete.ts @@ -82,14 +82,33 @@ function escapeHtml(text: string): string { } function sanitizeHighlightedHtml(text: string, { allowBreaks = false }: { allowBreaks?: boolean } = {}): string { - const sanitizedBreaks = allowBreaks - ? text.replace(/]*\/?>/gi, "
") - : text.replace(/]*\/?>/gi, ""); + const parser = new DOMParser(); + const doc = parser.parseFromString(text, "text/html"); + const safeOutput = document.createDocumentFragment(); - return sanitizedBreaks - .replace(/]*>/gi, "") - .replace(/<\/b\s*>/gi, "") - .replace(/<\/?[^>]+>/g, ""); + const processNode = (node: Node) => { + if (node.nodeType === Node.TEXT_NODE) { + safeOutput.appendChild(document.createTextNode(node.textContent || "")); + } else if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as Element; + if (el.tagName === "B") { + const b = document.createElement("b"); + b.textContent = el.textContent; // Only extract text, stripping nested potential danger + safeOutput.appendChild(b); + } else if (el.tagName === "BR" && allowBreaks) { + safeOutput.appendChild(document.createElement("br")); + } else { + // If the tag is not allowed, just extract its text content securely + safeOutput.appendChild(document.createTextNode(el.textContent || "")); + } + } + }; + + doc.body.childNodes.forEach(processNode); + + const tmpDiv = document.createElement("div"); + tmpDiv.appendChild(safeOutput); + return tmpDiv.innerHTML; } function normalizeAttributeSnippet(snippet: string): string { @@ -302,7 +321,7 @@ async function fetchResolvedSuggestions(term: string, options: Options = {}): Pr return commandSuggestions; } - const fastSearch = options.fastSearch === false ? false : true; + const fastSearch = options.fastSearch !== false; if (fastSearch === false) { if (term.trim().length === 0) { return []; @@ -804,9 +823,9 @@ function init() { $.fn.getSelectedNotePath = function () { if (!String($(this).val())?.trim()) { return ""; - } else { - return $(this).attr(SELECTED_NOTE_PATH_KEY); } + return $(this).attr(SELECTED_NOTE_PATH_KEY); + }; $.fn.getSelectedNoteId = function () { @@ -830,9 +849,9 @@ function init() { $.fn.getSelectedExternalLink = function () { if (!String($(this).val())?.trim()) { return ""; - } else { - return $(this).attr(SELECTED_EXTERNAL_LINK_KEY); } + return $(this).attr(SELECTED_EXTERNAL_LINK_KEY); + }; $.fn.setSelectedExternalLink = function (externalLink: string | null) {