From 404c8c8648950e61820e40f6ab0ebe47cc8c652e Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Sat, 4 Jul 2026 19:33:01 +0800 Subject: [PATCH 1/6] fix(muya): stop double percent-encoding autolink hrefs (#4841) A CommonMark autolink's href is the literal text between `< >`, which the author has already percent-encoded (e.g. `%20`). The renderer ran it through `encodeURI`, re-encoding the `%` and turning `%20` into `%2520`, so the followed link pointed at the wrong URL. Standard `[text](url)` links never encode the href, so autolinks now match by using it verbatim. Fixes #3548 Co-authored-by: Claude Opus 4.8 (1M context) --- .../__tests__/autoLinkEncoding.spec.ts | 56 +++++++++++++++++++ .../src/inlineRenderer/renderer/autoLink.ts | 2 +- 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 packages/muya/src/inlineRenderer/__tests__/autoLinkEncoding.spec.ts diff --git a/packages/muya/src/inlineRenderer/__tests__/autoLinkEncoding.spec.ts b/packages/muya/src/inlineRenderer/__tests__/autoLinkEncoding.spec.ts new file mode 100644 index 0000000000..38c81d8864 --- /dev/null +++ b/packages/muya/src/inlineRenderer/__tests__/autoLinkEncoding.spec.ts @@ -0,0 +1,56 @@ +// @vitest-environment happy-dom + +import type { Muya as MuyaType } from '../../muya'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { Muya } from '../../muya'; + +// #3548 — a CommonMark autolink's href is the literal source between `< >`, +// which the author already percent-encoded (`%20` etc). The renderer used +// `encodeURI(href)`, re-encoding the `%` to `%25` and turning `%20` into +// `%2520`, so the followed link pointed at the wrong URL. Standard +// `[text](url)` links never encode the href, so autolinks must match. + +const bootedHosts: HTMLElement[] = []; +let originalVersion: string | undefined; +let hadVersion = false; + +beforeEach(() => { + hadVersion = 'MUYA_VERSION' in window; + originalVersion = window.MUYA_VERSION; + window.MUYA_VERSION = 'test'; +}); + +afterEach(() => { + while (bootedHosts.length) + bootedHosts.pop()!.remove(); + document.getSelection()?.removeAllRanges(); + if (hadVersion) + window.MUYA_VERSION = originalVersion as string; + else + delete (window as Partial).MUYA_VERSION; +}); + +function bootMuya(markdown: string): MuyaType { + const host = document.createElement('div'); + document.body.appendChild(host); + const muya = new Muya(host, { markdown } as ConstructorParameters[1]); + muya.init(); + bootedHosts.push(muya.domNode); + return muya; +} + +describe('#3548 — autolink hrefs are not double percent-encoded', () => { + it('keeps an already-encoded `%20` intact instead of turning it into `%2520`', () => { + const muya = bootMuya('\n'); + const anchor = muya.domNode.querySelector('a.mu-auto-link')!; + const href = anchor.getAttribute('href')!; + expect(href).toContain('%20'); + expect(href).not.toContain('%2520'); + }); + + it('does not re-encode reserved characters in a query string', () => { + const muya = bootMuya('\n'); + const anchor = muya.domNode.querySelector('a.mu-auto-link')!; + expect(anchor.getAttribute('href')).toBe('https://example.com/a?b=c&d=e'); + }); +}); diff --git a/packages/muya/src/inlineRenderer/renderer/autoLink.ts b/packages/muya/src/inlineRenderer/renderer/autoLink.ts index cabaf87b3b..dd8dfbc48f 100644 --- a/packages/muya/src/inlineRenderer/renderer/autoLink.ts +++ b/packages/muya/src/inlineRenderer/renderer/autoLink.ts @@ -28,7 +28,7 @@ export default function autoLink( token, ); - const hyperlink = isLink ? encodeURI(href) : `mailto:${email}`; + const hyperlink = isLink ? href : `mailto:${email}`; return [ h(`span.${className}`, startMarker), From 1328c758b7dc11ca5250ba1ef3a763b63a053679 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Sat, 4 Jul 2026 19:43:01 +0800 Subject: [PATCH 2/6] fix(muya): keep pasted list items in order when merging mid-list (#4842) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pasting a same-kind list into a non-last item scrambled order: both merge paths pushed the pasted items onto the END of the enclosing list's children instead of inserting them right after the anchor item. Pasting `- 1/- 2/- 3` onto the empty middle item of `- A/- /- B` produced `- A/- 1/- B/- 2/- 3`. Splice the pasted items in after the anchor item, and — since they are no longer the list's last descendant — seat the caret on the last pasted item explicitly. Extracted the post-rebuild caret placement into `seatListMergeCursor` to keep `tryMergeListPaste` within the complexity budget. Fixes #3549 Co-authored-by: Claude Opus 4.8 (1M context) --- .../__tests__/pasteListMerge.spec.ts | 18 ++++++ packages/muya/src/clipboard/paste.ts | 56 +++++++++++++++---- 2 files changed, 63 insertions(+), 11 deletions(-) diff --git a/packages/muya/src/clipboard/__tests__/pasteListMerge.spec.ts b/packages/muya/src/clipboard/__tests__/pasteListMerge.spec.ts index 3eb8d1ab14..fb8d40a135 100644 --- a/packages/muya/src/clipboard/__tests__/pasteListMerge.spec.ts +++ b/packages/muya/src/clipboard/__tests__/pasteListMerge.spec.ts @@ -133,4 +133,22 @@ describe('paste — same-type list merges into the enclosing list (A5, muyajs pa expect(selection.anchor?.offset).toBe(4); expect(selection.focus?.offset).toBe(4); }); + + // #3549 — pasting into a non-last item must keep the pasted items in order + // right after the anchor item, not append them to the end of the list. + it('inserts pasted items after the anchor item, not at the list end', async () => { + const muya = bootMuya('- Item A\n- \n- Item B\n'); + const empty = contentBlocks(muya).find(b => b.text === '')!; + expect(await paste(muya, empty, 0, 0, '- Item 1\n- Item 2\n- Item 3')).toBe( + '- Item A\n- Item 1\n- Item 2\n- Item 3\n- Item B\n', + ); + }); + + it('keeps order for an ordered list pasted into the middle', async () => { + const muya = bootMuya('1. A\n2. \n3. B\n'); + const empty = contentBlocks(muya).find(b => b.text === '')!; + expect(await paste(muya, empty, 0, 0, '1. one\n2. two')).toBe( + '1. A\n2. one\n3. two\n4. B\n', + ); + }); }); diff --git a/packages/muya/src/clipboard/paste.ts b/packages/muya/src/clipboard/paste.ts index 7d00b85ed2..fcd497bbbd 100644 --- a/packages/muya/src/clipboard/paste.ts +++ b/packages/muya/src/clipboard/paste.ts @@ -239,6 +239,39 @@ function itemParaContent(list: Parent, itemIndex: number, paraIndex: number): Nu return para?.firstContentInDescendant() ?? null; } +interface IListMergeSeam { + foldedOnly: boolean; + itemIndex: number; + paraIndex: number; + head: string; + sewOffset: number; + canFold: boolean; + pastedCount: number; + trailingStates: TState[]; +} + +// Seat the caret after a list-merge rebuilds the list block: inside the folded +// paragraph, on trailing non-list content, or on the last pasted item (which is +// spliced in after the anchor, so it is no longer the list's last descendant). +function seatListMergeCursor(muya: Muya, newList: Parent, seam: IListMergeSeam): void { + const { foldedOnly, itemIndex, paraIndex, head, sewOffset, canFold, pastedCount, trailingStates } = seam; + if (foldedOnly) { + const cursor = itemParaContent(newList, itemIndex, paraIndex); + const offset = head.length + sewOffset; + cursor?.setCursor(offset, offset, true); + + return; + } + if (trailingStates.length > 0) { + const last = insertStatesAfter(muya, newList, trailingStates); + seatCursorAtSeam(last, sewOffset); + + return; + } + const lastPastedIndex = itemIndex + pastedCount - (canFold ? 1 : 0); + seatCursorAtSeam(newList.find(lastPastedIndex) as Nullable, sewOffset); +} + // Same list kind + same bullet marker / order delimiter. function listMarkersMatch(a: TState, b: TState): boolean { if (a.name === 'order-list' && b.name === 'order-list') @@ -301,7 +334,7 @@ function tryMergeListPaste( if (canFold) { anchorPara.text = head + pastedFirst.text; currentItem.children = [...currentItem.children, ...pastedItems[0].children.slice(1)]; - mergedChildren.push(...pastedItems.slice(1)); + mergedChildren.splice(itemIndex + 1, 0, ...pastedItems.slice(1)); // The whole paste folded into `anchorPara` (no extra blocks/items): the // caret stays in that paragraph at the seam. foldedOnly @@ -311,7 +344,7 @@ function tryMergeListPaste( } else { anchorPara.text = head; - mergedChildren.push(...pastedItems); + mergedChildren.splice(itemIndex + 1, 0, ...pastedItems); } const loose = listState.meta.loose || firstState.meta.loose; @@ -327,15 +360,16 @@ function tryMergeListPaste( ); listBlock.replaceWith(newList); - if (foldedOnly) { - const cursor = itemParaContent(newList, itemIndex, paraIndex); - const offset = head.length + sewOffset; - cursor?.setCursor(offset, offset, true); - } - else { - const last = insertStatesAfter(clipboard.muya, newList, states.slice(1)); - seatCursorAtSeam(last, sewOffset); - } + seatListMergeCursor(clipboard.muya, newList as Parent, { + foldedOnly, + itemIndex, + paraIndex, + head, + sewOffset, + canFold, + pastedCount: pastedItems.length, + trailingStates: states.slice(1), + }); return true; } From abd81c1a816e4053ee00c9ce7624ab727164464e Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Sat, 4 Jul 2026 20:07:22 +0800 Subject: [PATCH 3/6] fix(desktop): confirm before opening executable link targets (code execution) (#4843) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(common): add isDangerousExecutableFile guard Add a pure extension-based check (and the backing extension list) for files the OS shell would execute as code — Windows Script Host scripts (js, vbs, wsf, hta…), native executables/installers, batch, PowerShell, and shortcut/ registry/JVM launchers. Case-insensitive; no filesystem access so it can gate a decision before the file is opened. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(desktop): confirm before opening executable link targets Clicking a markdown link to a co-located local file called shell.openPath with no safety check. On Windows a link like `[open](./update.js)` next to an untrusted document ran the script through wscript.exe with no prompt, giving silent code execution. Guard the non-markdown local-file branch of mt::format-link-click with isDangerousExecutableFile and show a warning dialog (default Cancel) before opening; the file is only handed to the OS shell if the user confirms. Fixes #3575 Co-Authored-By: Claude Opus 4.8 (1M context) * fix(common): harden isDangerousExecutableFile (review #4843) Two gaps from review: - Trailing dot/space bypass: `update.js.` and `<./update.js >` (an angle- bracket link skips the space rejection) reach the guard with an extension of `.` / `js ` that isn't listed, yet Windows strips trailing dots/spaces during ShellExecute and still runs `update.js`. Trim trailing `[ .]+` before reading the extension. - Windows-only coverage: the vulnerable shell.openPath path is cross-platform, so add macOS (`command`, `app`) and Linux (`desktop`, `appimage`, `run`) launchers — `[x](./run.command)` / `./launch.desktop` reproduced #3575 off Windows. Co-Authored-By: Claude Opus 4.8 (1M context) * i18n: add unsafe-file dialog strings to all locales Add dialog.unsafeFileTitle / unsafeFileMessage / unsafeFileDetail ({name}) and dialog.openAnyway to the ten locale files so the #3575 confirmation dialog is localized (getTranslation returns the raw key when one is missing). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(desktop): localize the unsafe-file confirmation dialog (review #4843) Route the dialog title/message/detail and buttons through t('dialog.…') instead of hardcoded English, matching the other dialogs in this file. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../desktop/src/common/filesystem/paths.ts | 68 +++++++++++++++++++ .../desktop/src/main/menu/actions/file.ts | 21 +++++- packages/desktop/static/locales/de.json | 6 +- packages/desktop/static/locales/en.json | 6 +- packages/desktop/static/locales/es.json | 6 +- packages/desktop/static/locales/fr.json | 6 +- packages/desktop/static/locales/ja.json | 6 +- packages/desktop/static/locales/ko.json | 6 +- packages/desktop/static/locales/pt.json | 6 +- packages/desktop/static/locales/tr.json | 6 +- packages/desktop/static/locales/zh-CN.json | 6 +- packages/desktop/static/locales/zh-TW.json | 6 +- .../specs/dangerous-executable-file.spec.ts | 62 +++++++++++++++++ 13 files changed, 199 insertions(+), 12 deletions(-) create mode 100644 packages/desktop/test/unit/specs/dangerous-executable-file.spec.ts diff --git a/packages/desktop/src/common/filesystem/paths.ts b/packages/desktop/src/common/filesystem/paths.ts index 57e1704f76..fc465f64b4 100644 --- a/packages/desktop/src/common/filesystem/paths.ts +++ b/packages/desktop/src/common/filesystem/paths.ts @@ -32,6 +32,74 @@ export const IMAGE_EXTENSIONS: readonly string[] = Object.freeze([ 'webp' ]) +// Extensions the OS shell will execute rather than open in an application. +// Opening one of these via shell.openPath runs code, so a markdown link +// pointing at a co-located script/executable must be confirmed first (#3575). +// The vulnerable path is cross-platform, so the list covers Windows, macOS and +// Linux launchers — not just Windows. +export const DANGEROUS_EXECUTABLE_EXTENSIONS: readonly string[] = Object.freeze([ + // Windows — native executables, installers and control-panel items + 'exe', + 'com', + 'scr', + 'pif', + 'cpl', + 'msi', + 'msp', + 'msc', + 'gadget', + 'application', + // Windows — shell / batch + 'bat', + 'cmd', + // Windows Script Host + 'js', + 'jse', + 'vbs', + 'vbe', + 'wsf', + 'wsh', + 'ws', + 'wsc', + 'hta', + // PowerShell + 'ps1', + 'ps1xml', + 'ps2', + 'ps2xml', + 'psc1', + 'psc2', + 'psd1', + 'psm1', + // Windows — shortcuts, registry and JVM launchers + 'lnk', + 'inf', + 'reg', + 'scf', + 'jar', + 'jnlp', + // macOS — Terminal scripts and app bundles + 'command', + 'app', + // Linux — desktop entries and self-contained executables + 'desktop', + 'appimage', + 'run' +]) + +/** + * Returns true if the path's extension is one the OS will execute as code + * (script or binary), so opening it warrants a confirmation prompt. + */ +export const isDangerousExecutableFile = (filepath: string): boolean => { + if (!filepath || typeof filepath !== 'string') return false + // Windows strips trailing dots/spaces during ShellExecute canonicalization, + // so `update.js.` / `<./update.js >` still run `update.js` — strip them + // before reading the extension or the guard is trivially bypassed. + const ext = path.extname(filepath.replace(/[ .]+$/, '')).slice(1).toLowerCase() + return !!ext && DANGEROUS_EXECUTABLE_EXTENSIONS.includes(ext) +} + /** * Returns true if the filename matches one of the markdown extensions. */ diff --git a/packages/desktop/src/main/menu/actions/file.ts b/packages/desktop/src/main/menu/actions/file.ts index e91b9b97d5..4ff54302d7 100644 --- a/packages/desktop/src/main/menu/actions/file.ts +++ b/packages/desktop/src/main/menu/actions/file.ts @@ -11,7 +11,7 @@ import { } from 'electron' import log from 'electron-log' import { isDirectory, isFile, exists } from 'common/filesystem' -import { MARKDOWN_EXTENSIONS, isMarkdownFile } from 'common/filesystem/paths' +import { MARKDOWN_EXTENSIONS, isDangerousExecutableFile, isMarkdownFile } from 'common/filesystem/paths' import { checkUpdates, userSetting } from './marktext' import { showTabBar } from './view' import { COMMANDS } from '../../commands' @@ -583,7 +583,7 @@ interface FormatLinkPayload { dirname?: string } -ipcMain.on('mt::format-link-click', (e, { data, dirname }: FormatLinkPayload) => { +ipcMain.on('mt::format-link-click', async(e, { data, dirname }: FormatLinkPayload) => { if (!data || (!data.href && !data.text)) { return } @@ -629,6 +629,23 @@ ipcMain.on('mt::format-link-click', (e, { data, dirname }: FormatLinkPayload) => openFileOrFolder(innerWin, pathname) } } else { + // A link in an untrusted document could point at a co-located script or + // executable; opening it via the OS shell would run code silently (#3575). + if (isDangerousExecutableFile(pathname)) { + const { response } = await dialog.showMessageBox(win, { + type: 'warning', + buttons: [t('dialog.cancel'), t('dialog.openAnyway')], + defaultId: 0, + cancelId: 0, + noLink: true, + title: t('dialog.unsafeFileTitle'), + message: t('dialog.unsafeFileMessage'), + detail: t('dialog.unsafeFileDetail', { name: path.basename(pathname) }) + }) + if (response !== 1) { + return + } + } shell.openPath(pathname) } } diff --git a/packages/desktop/static/locales/de.json b/packages/desktop/static/locales/de.json index e75059cc83..bbd1acb248 100644 --- a/packages/desktop/static/locales/de.json +++ b/packages/desktop/static/locales/de.json @@ -889,7 +889,11 @@ "replace": "Ersetzen", "save": "Speichern", "saveChanges": "Änderungen speichern", - "saveFailure": "Speichern fehlgeschlagen" + "saveFailure": "Speichern fehlgeschlagen", + "unsafeFileTitle": "Möglicherweise unsichere Datei", + "unsafeFileMessage": "Dieser Link öffnet eine Datei, die Code ausführen kann", + "unsafeFileDetail": "„{name}“ ist eine ausführbare Datei oder ein Skript. Das Öffnen kann Programme auf Ihrem Computer ausführen. Fahren Sie nur fort, wenn Sie diesem Dokument vertrauen.", + "openAnyway": "Trotzdem öffnen" }, "error": { "configSchemaViolation": "Konfigurationsschema-Verletzung", diff --git a/packages/desktop/static/locales/en.json b/packages/desktop/static/locales/en.json index 8fcefb828b..e56f616389 100644 --- a/packages/desktop/static/locales/en.json +++ b/packages/desktop/static/locales/en.json @@ -889,7 +889,11 @@ "replace": "Replace", "save": "Save", "saveChanges": "Save changes", - "saveFailure": "Save failure" + "saveFailure": "Save failure", + "unsafeFileTitle": "Potentially unsafe file", + "unsafeFileMessage": "This link opens a file that can run code", + "unsafeFileDetail": "\"{name}\" is an executable or script file. Opening it may run programs on your computer. Only continue if you trust this document.", + "openAnyway": "Open Anyway" }, "error": { "configSchemaViolation": "Configuration schema violation", diff --git a/packages/desktop/static/locales/es.json b/packages/desktop/static/locales/es.json index 114edb0517..9db59943c6 100644 --- a/packages/desktop/static/locales/es.json +++ b/packages/desktop/static/locales/es.json @@ -889,7 +889,11 @@ "replace": "Reemplazar", "save": "Guardar", "saveChanges": "Guardar cambios", - "saveFailure": "Error al guardar" + "saveFailure": "Error al guardar", + "unsafeFileTitle": "Archivo potencialmente peligroso", + "unsafeFileMessage": "Este enlace abre un archivo que puede ejecutar código", + "unsafeFileDetail": "«{name}» es un archivo ejecutable o de script. Abrirlo podría ejecutar programas en tu equipo. Continúa solo si confías en este documento.", + "openAnyway": "Abrir de todos modos" }, "error": { "configSchemaViolation": "Violación del esquema de configuración", diff --git a/packages/desktop/static/locales/fr.json b/packages/desktop/static/locales/fr.json index 5ba30b45bb..216696e3ea 100644 --- a/packages/desktop/static/locales/fr.json +++ b/packages/desktop/static/locales/fr.json @@ -889,7 +889,11 @@ "replace": "Remplacer", "save": "Enregistrer", "saveChanges": "Enregistrer les modifications", - "saveFailure": "Échec de l'enregistrement" + "saveFailure": "Échec de l'enregistrement", + "unsafeFileTitle": "Fichier potentiellement dangereux", + "unsafeFileMessage": "Ce lien ouvre un fichier pouvant exécuter du code", + "unsafeFileDetail": "« {name} » est un fichier exécutable ou un script. L’ouvrir pourrait exécuter des programmes sur votre ordinateur. Ne continuez que si vous faites confiance à ce document.", + "openAnyway": "Ouvrir quand même" }, "error": { "configSchemaViolation": "Violation du schéma de configuration", diff --git a/packages/desktop/static/locales/ja.json b/packages/desktop/static/locales/ja.json index 4c43940c99..f48c1ad419 100644 --- a/packages/desktop/static/locales/ja.json +++ b/packages/desktop/static/locales/ja.json @@ -889,7 +889,11 @@ "replace": "置換", "save": "保存", "saveChanges": "変更を保存", - "saveFailure": "保存に失敗" + "saveFailure": "保存に失敗", + "unsafeFileTitle": "安全でない可能性のあるファイル", + "unsafeFileMessage": "このリンクはコードを実行できるファイルを開きます", + "unsafeFileDetail": "「{name}」は実行可能ファイルまたはスクリプトファイルです。開くとコンピューター上でプログラムが実行される可能性があります。この文書を信頼できる場合のみ続行してください。", + "openAnyway": "それでも開く" }, "error": { "configSchemaViolation": "設定スキーマ違反", diff --git a/packages/desktop/static/locales/ko.json b/packages/desktop/static/locales/ko.json index 9663a4ae84..84a9ea2bba 100644 --- a/packages/desktop/static/locales/ko.json +++ b/packages/desktop/static/locales/ko.json @@ -889,7 +889,11 @@ "replace": "바꾸기", "save": "저장", "saveChanges": "변경 사항 저장", - "saveFailure": "저장 실패" + "saveFailure": "저장 실패", + "unsafeFileTitle": "안전하지 않을 수 있는 파일", + "unsafeFileMessage": "이 링크는 코드를 실행할 수 있는 파일을 엽니다", + "unsafeFileDetail": "\"{name}\"은(는) 실행 파일 또는 스크립트 파일입니다. 열면 컴퓨터에서 프로그램이 실행될 수 있습니다. 이 문서를 신뢰하는 경우에만 계속하세요.", + "openAnyway": "그래도 열기" }, "error": { "configSchemaViolation": "구성 스키마 위반", diff --git a/packages/desktop/static/locales/pt.json b/packages/desktop/static/locales/pt.json index 58973e6533..c8f4aceb1c 100644 --- a/packages/desktop/static/locales/pt.json +++ b/packages/desktop/static/locales/pt.json @@ -889,7 +889,11 @@ "replace": "Substituir", "save": "Salvar", "saveChanges": "Salvar alterações", - "saveFailure": "Falha ao salvar" + "saveFailure": "Falha ao salvar", + "unsafeFileTitle": "Arquivo potencialmente inseguro", + "unsafeFileMessage": "Este link abre um arquivo que pode executar código", + "unsafeFileDetail": "\"{name}\" é um arquivo executável ou de script. Abri-lo pode executar programas no seu computador. Continue apenas se confiar neste documento.", + "openAnyway": "Abrir mesmo assim" }, "error": { "configSchemaViolation": "Violação do esquema de configuração", diff --git a/packages/desktop/static/locales/tr.json b/packages/desktop/static/locales/tr.json index 4f07425aad..27f46a2a69 100644 --- a/packages/desktop/static/locales/tr.json +++ b/packages/desktop/static/locales/tr.json @@ -889,7 +889,11 @@ "replace": "Değiştir", "save": "Kaydet", "saveChanges": "Değişiklikleri kaydet", - "saveFailure": "Kaydetme hatası" + "saveFailure": "Kaydetme hatası", + "unsafeFileTitle": "Güvenli olmayabilecek dosya", + "unsafeFileMessage": "Bu bağlantı, kod çalıştırabilen bir dosyayı açar", + "unsafeFileDetail": "\"{name}\" bir yürütülebilir veya betik dosyasıdır. Açmak, bilgisayarınızda program çalıştırabilir. Yalnızca bu belgeye güveniyorsanız devam edin.", + "openAnyway": "Yine de aç" }, "error": { "configSchemaViolation": "Yapılandırma şeması ihlali", diff --git a/packages/desktop/static/locales/zh-CN.json b/packages/desktop/static/locales/zh-CN.json index 3b65d7ca66..d32ecab6fa 100644 --- a/packages/desktop/static/locales/zh-CN.json +++ b/packages/desktop/static/locales/zh-CN.json @@ -889,7 +889,11 @@ "replace": "替换", "save": "保存", "saveChanges": "保存修改", - "saveFailure": "保存失败" + "saveFailure": "保存失败", + "unsafeFileTitle": "潜在不安全的文件", + "unsafeFileMessage": "此链接将打开一个可执行代码的文件", + "unsafeFileDetail": "“{name}”是可执行文件或脚本文件。打开它可能会在你的计算机上运行程序。请仅在信任此文档时继续。", + "openAnyway": "仍然打开" }, "error": { "configSchemaViolation": "配置架构违规", diff --git a/packages/desktop/static/locales/zh-TW.json b/packages/desktop/static/locales/zh-TW.json index 15c66f5e40..17621c18c0 100644 --- a/packages/desktop/static/locales/zh-TW.json +++ b/packages/desktop/static/locales/zh-TW.json @@ -889,7 +889,11 @@ "replace": "取代", "save": "儲存", "saveChanges": "儲存變更", - "saveFailure": "儲存失敗" + "saveFailure": "儲存失敗", + "unsafeFileTitle": "潛在不安全的檔案", + "unsafeFileMessage": "此連結將開啟一個可執行程式碼的檔案", + "unsafeFileDetail": "「{name}」是可執行檔或指令碼檔案。開啟它可能會在你的電腦上執行程式。請僅在信任此文件時繼續。", + "openAnyway": "仍要開啟" }, "error": { "configSchemaViolation": "設定結構違規", diff --git a/packages/desktop/test/unit/specs/dangerous-executable-file.spec.ts b/packages/desktop/test/unit/specs/dangerous-executable-file.spec.ts new file mode 100644 index 0000000000..138885d238 --- /dev/null +++ b/packages/desktop/test/unit/specs/dangerous-executable-file.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { isDangerousExecutableFile } from 'common/filesystem/paths' + +// #3575 — clicking a markdown link to a co-located script/executable used to +// call shell.openPath() with no check, so a `.js`/`.vbs`/`.bat` next to an +// untrusted document ran code (WSH JScript) on Windows without confirmation. +// This guard flags those extensions so the handler can confirm before opening. + +describe('#3575 — isDangerousExecutableFile', () => { + it('flags Windows Script Host script files', () => { + for (const ext of ['js', 'jse', 'vbs', 'vbe', 'wsf', 'wsh', 'ws', 'wsc', 'hta']) { + expect(isDangerousExecutableFile(`payload.${ext}`)).toBe(true) + } + }) + + it('flags native executables, installers and batch files', () => { + for (const ext of ['exe', 'com', 'scr', 'pif', 'cpl', 'msi', 'msp', 'bat', 'cmd']) { + expect(isDangerousExecutableFile(`payload.${ext}`)).toBe(true) + } + }) + + it('flags PowerShell and shortcut/registry files', () => { + for (const ext of ['ps1', 'psm1', 'lnk', 'reg', 'inf', 'scf', 'jar']) { + expect(isDangerousExecutableFile(`payload.${ext}`)).toBe(true) + } + }) + + it('is case-insensitive and tolerates an absolute path', () => { + expect(isDangerousExecutableFile('C:\\Users\\a\\Update.JS')).toBe(true) + expect(isDangerousExecutableFile('/tmp/run.VBS')).toBe(true) + }) + + it('flags macOS and Linux launchers, not just Windows', () => { + for (const name of ['run.command', 'Foo.app', 'launch.desktop', 'App.AppImage', 'installer.run']) { + expect(isDangerousExecutableFile(name)).toBe(true) + } + }) + + it('still flags when a trailing dot or space would slip past ShellExecute (#4843 review)', () => { + // Windows strips trailing dots/spaces, so these still run update.js. + expect(isDangerousExecutableFile('update.js.')).toBe(true) + expect(isDangerousExecutableFile('update.js ')).toBe(true) + expect(isDangerousExecutableFile('payload.exe...')).toBe(true) + expect(isDangerousExecutableFile('payload.bat ')).toBe(true) + }) + + it('does not misflag a safe file that merely ends in a dot/space', () => { + expect(isDangerousExecutableFile('note.md.')).toBe(false) + expect(isDangerousExecutableFile('photo.png ')).toBe(false) + }) + + it('does not flag documents, images or markdown', () => { + for (const name of ['note.md', 'photo.png', 'data.json', 'readme.txt', 'archive.zip', 'index.html']) { + expect(isDangerousExecutableFile(name)).toBe(false) + } + }) + + it('does not flag a file with no extension or an empty input', () => { + expect(isDangerousExecutableFile('Makefile')).toBe(false) + expect(isDangerousExecutableFile('')).toBe(false) + }) +}) From 3bc6447fa956bb076d85b968fc25584a58c22e1c Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Sat, 4 Jul 2026 20:48:40 +0800 Subject: [PATCH 4/6] fix(muya): keep soft line breaks on export via CSS, not
(#4844) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A soft line break (Shift+Enter, serialized as a bare `\n` inside a block) shows as a line break in the editor (`.mu-content` is pre-wrap) but was lost on export: marked renders a soft break as a space, so the two lines ran together in the exported HTML/PDF. Instead of forcing marked to emit `
` (which CommonMark reserves for hard breaks — it would make the exported HTML non-conformant), keep the conformant `\n` and render it the way the editor does, with `white-space: pre-wrap` in exportStyle.css: - `.markdown-body p` covers paragraphs, blockquotes and loose list items (whose content is wrapped in `

`). - `.markdown-body li:not(:has(> p))` covers tight list items, whose soft break is a bare `\n` directly inside the `

  • `. Loose items are excluded so marked's pretty-printing newline between `

    ` and `
  • ` is not exposed as a stray blank line — no DOM post-processing needed. The exported HTML now matches the editor while staying CommonMark-conformant (soft break = line ending; hard breaks still `
    `). Verified in real Chromium that the paragraph and tight-item breaks render and the loose item has no stray blank line. Fixes #3676 Co-authored-by: Claude Opus 4.8 (1M context) --- .../muya/src/assets/styles/exportStyle.css | 16 +++++++++ .../__tests__/softBreakExportHtml.spec.ts | 36 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 packages/muya/src/state/__tests__/softBreakExportHtml.spec.ts diff --git a/packages/muya/src/assets/styles/exportStyle.css b/packages/muya/src/assets/styles/exportStyle.css index e362724954..3e7b5aad52 100644 --- a/packages/muya/src/assets/styles/exportStyle.css +++ b/packages/muya/src/assets/styles/exportStyle.css @@ -89,6 +89,22 @@ white-space: pre-wrap; } +/* Render soft line breaks (Shift+Enter → a bare `\n` inside a block) the way + the editor does (`.mu-content` is pre-wrap) instead of emitting a + non-standard `
    `, so the exported HTML stays CommonMark-conformant (the + `\n` is a plain line ending) yet still shows the break (#3676). + + `li:not(:has(> p))` targets only tight list items, whose soft break is a + bare `\n` directly inside the `
  • `. Loose items wrap their content in + `

    ` (handled by the `p` rule); giving them `li` pre-wrap would expose + marked's pretty-printing newline between `

    ` and `
  • ` as a stray blank + line, so they are deliberately excluded. Specificity stays level with the + earlier `.toc-container ul li` rule (no-descending-specificity). */ +.markdown-body p, +.markdown-body li:not(:has(> p)) { + white-space: pre-wrap; +} + .markdown-body table { display: table; } diff --git a/packages/muya/src/state/__tests__/softBreakExportHtml.spec.ts b/packages/muya/src/state/__tests__/softBreakExportHtml.spec.ts new file mode 100644 index 0000000000..2e09cf778d --- /dev/null +++ b/packages/muya/src/state/__tests__/softBreakExportHtml.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { getHighlightHtml } from '../../utils/marked'; + +// #3676 — a soft line break (Shift+Enter, serialized as a bare `\n` inside a +// block) shows as a line break in the editor (`.mu-content` is pre-wrap) but +// was lost on export because marked renders a soft break as a space. Rather +// than emit a non-standard `
    ` (which CommonMark reserves for hard breaks), +// the export keeps the conformant `\n` and renders it with `white-space: +// pre-wrap` on `.markdown-body p` and `li:not(:has(> p))` (tight items only). +// +// These assert the HTML stays conformant — the soft break is a preserved +// newline, never a `
    `, and hard breaks are untouched. The pre-wrap +// rendering itself (and the `:has()` exclusion of loose items) is a CSS +// concern verified in a real browser against the export stylesheet. + +const OPTS = { math: false, superSubScript: false, footnote: false, frontMatter: false }; + +describe('#3676 — soft line breaks survive export as a conformant newline', () => { + it('keeps a paragraph soft break as a newline, never a
    ', () => { + const html = getHighlightHtml('line one\nline two', OPTS); + expect(html).toContain('

    line one\nline two

    '); + expect(html).not.toMatch(//); + }); + + it('keeps a soft break inside a tight list item, never a
    ', () => { + const html = getHighlightHtml('- line A\n line B', OPTS); + expect(html).toMatch(/
  • line A\nline B<\/li>/); + expect(html).not.toMatch(//); + }); + + it('leaves a real hard break (two trailing spaces) as
    ', () => { + // Sanity: the change only touches soft breaks; hard breaks are untouched. + const html = getHighlightHtml('line one \nline two', OPTS); + expect(html).toMatch(//); + }); +}); From b62ed22e9d0227aea962b095a48f3914bb2dc212 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Sat, 4 Jul 2026 21:36:54 +0800 Subject: [PATCH 5/6] fix(muya): show escaped pipe in table cell code as | not \| (#4849) (#4850) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An escaped pipe inside a table cell's inline code (`` `\|` ``) was displayed with the backslash — `\|` — instead of the intended `|`. GFM escapes a literal `|` in a table cell as `\|`; once the table is parsed that escape is a literal pipe, so `` `\|` `` must render as `|` (which the HTML/PDF export already does). On import, `restoreTableEscapeCharacters` re-added the `\|` escape into the stored cell text. Outside code the backslash rule hides it, but inside inline code (where escapes don't apply) the backslash leaked into the display. That re-escaping was also redundant: `stateToMarkdown.escapeText` already re-escapes unescaped `|` in a cell on serialization. Store the cell text as marked emits it (escape already resolved to `|`) and let `escapeText` re-add the escape on the way out. The editor now shows the pipe correctly and the markdown still round-trips to `` `\|` ``. Co-authored-by: Claude Opus 4.8 (1M context) --- .../state/__tests__/tableEscapedPipe.spec.ts | 63 +++++++++++++++++++ packages/muya/src/state/markdownToState.ts | 14 ++--- 2 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 packages/muya/src/state/__tests__/tableEscapedPipe.spec.ts diff --git a/packages/muya/src/state/__tests__/tableEscapedPipe.spec.ts b/packages/muya/src/state/__tests__/tableEscapedPipe.spec.ts new file mode 100644 index 0000000000..d1c0f1a799 --- /dev/null +++ b/packages/muya/src/state/__tests__/tableEscapedPipe.spec.ts @@ -0,0 +1,63 @@ +// @vitest-environment happy-dom + +// #4849: an escaped pipe inside a table cell's inline code (`` `\|` ``) was +// displayed with the backslash (`\|`) instead of the intended `|`. GFM escapes +// `|` in table cells with a backslash; after the table is parsed that escape is +// a literal `|`, so `` `\|` `` must render as | (as the HTML/PDF +// export already does). The stored cell text re-added the escape, which leaks +// into the editor's inline-code display (the backslash rule doesn't apply +// inside code). Serialization re-escapes on its own, so the round-trip is kept. + +import { describe, expect, it } from 'vitest'; +import { Muya } from '../../muya'; +import { MarkdownToState } from '../markdownToState'; +import ExportMarkdown from '../stateToMarkdown'; + +function boot(markdown: string): Muya { + const host = document.createElement('div'); + document.body.appendChild(host); + const muya = new Muya(host, { markdown } as ConstructorParameters[1]); + muya.init(); + return muya; +} + +function codeTexts(muya: Muya): string[] { + return [...muya.domNode!.querySelectorAll('td code')].map(c => c.textContent ?? ''); +} + +function roundTrip(md: string): string { + const states = new MarkdownToState().generate(md); + return new ExportMarkdown({ listIndentation: 1 }).generate(states); +} + +const TABLE = [ + '| a | b |', + '| --- | --- |', + '| `\\|` | x |', + '| `\\|\\|` | y |', + '', +].join('\n'); + +describe('#4849: escaped pipe in a table cell', () => { + it('renders `\\|` inside code as | (no backslash) in the editor', () => { + const muya = boot(TABLE); + expect(codeTexts(muya)).toEqual(['|', '||']); + }); + + it('keeps the table structure (2 columns, 3 rows)', () => { + const muya = boot(TABLE); + expect(muya.domNode!.querySelectorAll('tr').length).toBe(3); + expect(muya.domNode!.querySelectorAll('tr')[2].querySelectorAll('td').length).toBe(2); + }); + + it('round-trips the escaped pipes back to `\\|` / `\\|\\|`', () => { + const md = roundTrip(TABLE); + expect(md).toContain('`\\|`'); + expect(md).toContain('`\\|\\|`'); + }); + + it('round-trips an escaped pipe in plain cell text', () => { + const md = roundTrip('| a | b |\n| --- | --- |\n| x \\| y | z |\n'); + expect(md).toContain('x \\| y'); + }); +}); diff --git a/packages/muya/src/state/markdownToState.ts b/packages/muya/src/state/markdownToState.ts index 2b3f5efdec..32b6ea92b1 100644 --- a/packages/muya/src/state/markdownToState.ts +++ b/packages/muya/src/state/markdownToState.ts @@ -14,11 +14,6 @@ import logger from '../utils/logger'; import { lexBlock } from '../utils/marked'; const debug = logger('import markdown: '); -function restoreTableEscapeCharacters(text: string) { - // NOTE: markedjs replaces all escaped "|" ("\|") characters inside a cell with "|". - // We have to re-escape the character to not break the table. - return text.replace(/\|/g, '\\|'); -} interface IMarkdownToStateOptions { footnote: boolean; @@ -299,12 +294,17 @@ export class MarkdownToState { children: [], }; + // Store the cell text as marked emits it (with the table `\|` + // escape already resolved to a literal `|`), so the editor shows + // `` `|` `` rather than the escaped `` `\|` `` inside inline code + // (#4849). `escapeText` re-adds the `\|` escape on serialization, + // keeping the markdown round-trip intact. tableState.children.push({ name: 'table.row', children: header.map((h, i) => ({ name: 'table.cell' as const, meta: { align: align[i] || 'none' }, - text: restoreTableEscapeCharacters(h.text), + text: h.text, })), }); @@ -314,7 +314,7 @@ export class MarkdownToState { children: row.map((c, i) => ({ name: 'table.cell' as const, meta: { align: align[i] || 'none' }, - text: restoreTableEscapeCharacters(c.text), + text: c.text, })), })), ); From 5f54c09c5b7fafc08cb32ba964e25b6ff4bb8c6f Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Sat, 4 Jul 2026 21:54:56 +0800 Subject: [PATCH 6/6] fix(muya): preserve fenced code block info string on round-trip (#4770) (#4846) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fenced code block's info string was reduced to its first word on save: only `meta.lang` (used for syntax highlighting) was kept, and the serializer emitted just that word. So ```` ```{example, listing1-name} ```` was rewritten to ```` ```{example, ```` — and ```` ```js title="app.js" ```` lost its attributes — the moment the document was saved. `meta.lang` must stay a single word: the code-block content adds a `language-${lang}` class and a lang with spaces would break `classList.add`. So preserve the full info string additively in a new optional `meta.info`, set at parse time when the info string carries more than the language word, and emit it on serialize. The language word still drives highlighting; the fence now round-trips losslessly. Serialization only trusts `meta.info` while `lang` is still its first word, so editing the language (which rewrites `lang`) correctly drops the stale attributes instead of re-emitting them. Co-authored-by: Claude Opus 4.8 (1M context) --- .../__tests__/codeFenceInfoString.spec.ts | 53 +++++++++++++++++++ packages/muya/src/state/markdownToState.ts | 5 ++ packages/muya/src/state/stateToMarkdown.ts | 8 ++- packages/muya/src/state/types.ts | 5 ++ 4 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 packages/muya/src/state/__tests__/codeFenceInfoString.spec.ts diff --git a/packages/muya/src/state/__tests__/codeFenceInfoString.spec.ts b/packages/muya/src/state/__tests__/codeFenceInfoString.spec.ts new file mode 100644 index 0000000000..b27a950e59 --- /dev/null +++ b/packages/muya/src/state/__tests__/codeFenceInfoString.spec.ts @@ -0,0 +1,53 @@ +// Regression for #4770: a fenced code block's info string must survive a +// markdown -> state -> markdown round-trip. MarkText used only the first word +// of the info string as the language (for highlighting) and serialized just +// that word back, so `` ```{example, listing1-name} `` was rewritten to +// `` ```{example, `` on save — dropping everything after the first space. + +import { describe, expect, it } from 'vitest'; +import { MarkdownToState } from '../markdownToState'; +import ExportMarkdown from '../stateToMarkdown'; + +function roundTrip(md: string): string { + const states = new MarkdownToState().generate(md); + return new ExportMarkdown({ listIndentation: 1 }).generate(states); +} + +describe('#4770: fenced code block info string round-trip', () => { + it('preserves a Pandoc/RMarkdown-style attribute info string', () => { + const md = '```{example, listing1-name}\nlabel for code listing 1\n```\n'; + expect(roundTrip(md)).toContain('```{example, listing1-name}'); + }); + + it('preserves a language followed by attributes', () => { + const md = '```js title="app.js"\nconst a = 1\n```\n'; + expect(roundTrip(md)).toContain('```js title="app.js"'); + }); + + it('leaves a plain single-word language unchanged (no regression)', () => { + const out = roundTrip('```js\nconst a = 1\n```\n'); + expect(out).toContain('```js\n'); + }); + + it('leaves a language-less fence unchanged (no regression)', () => { + const out = roundTrip('```\nplain\n```\n'); + expect(out).toContain('```\n'); + expect(out).not.toContain('```undefined'); + }); + + it('uses the edited language when a stored info string is now stale', () => { + // Emulates the user opening ```{example, listing1-name} then changing the + // language to `python` via the language input: `lang` no longer matches the + // stored info's first word, so serialization must emit the edited language. + const states = [ + { + name: 'code-block' as const, + meta: { type: 'fenced', lang: 'python', info: '{example, listing1-name}' }, + text: 'x', + }, + ]; + const out = new ExportMarkdown({ listIndentation: 1 }).generate(states); + expect(out).toContain('```python\n'); + expect(out).not.toContain('example'); + }); +}); diff --git a/packages/muya/src/state/markdownToState.ts b/packages/muya/src/state/markdownToState.ts index 32b6ea92b1..facfeca41a 100644 --- a/packages/muya/src/state/markdownToState.ts +++ b/packages/muya/src/state/markdownToState.ts @@ -449,12 +449,17 @@ export class MarkdownToState { // but `'fenced'` reaches us at runtime via the // walkTokens assignment — hence the cast. const isFenced = (codeBlockStyle as 'indented' | 'fenced' | undefined) === 'fenced'; + // Preserve the full info string when it carries more than the language + // word (attributes like `title="x"`, or a Pandoc/RMarkdown `{…}` block), + // so the fence round-trips instead of collapsing to its first word (#4770). + const info = infoString || ''; return { name: 'code-block' as const, meta: { type: isFenced ? 'fenced' : 'indented', lang, ...(isFenced && fenceLength && fenceLength > 3 ? { fenceLength } : {}), + ...(isFenced && info !== lang ? { info } : {}), }, text: value, }; diff --git a/packages/muya/src/state/stateToMarkdown.ts b/packages/muya/src/state/stateToMarkdown.ts index 1945ccf9a0..4064479bc4 100644 --- a/packages/muya/src/state/stateToMarkdown.ts +++ b/packages/muya/src/state/stateToMarkdown.ts @@ -354,10 +354,16 @@ export default class ExportMarkdown { const { text, meta } = state; const textList = text.split('\n'); const { type, lang } = meta; + // Emit the preserved full info string (`js title="x"`, Pandoc `{…}` + // attributes), but only while `lang` is still its first word: once the + // language is edited the stored info is stale, so the edited language + // wins (#4770). + const info + = meta.info && meta.info.match(/\S*/)?.[0] === lang ? meta.info : lang; if (type === 'fenced') { const fence = '`'.repeat(this._codeFenceLength(text, meta.fenceLength)); - result.push(`${indent}${lang ? `${fence}${lang}\n` : `${fence}\n`}`); + result.push(`${indent}${info ? `${fence}${info}\n` : `${fence}\n`}`); textList.forEach((text) => { result.push(`${indent}${text}\n`); }); diff --git a/packages/muya/src/state/types.ts b/packages/muya/src/state/types.ts index ef0c7c6f63..3500d3fa76 100644 --- a/packages/muya/src/state/types.ts +++ b/packages/muya/src/state/types.ts @@ -31,6 +31,11 @@ export interface ICodeBlockState { type: string; // "indented" | "fenced"; lang: string; fenceLength?: number; + // Full fenced info string when it carries more than the language word + // (e.g. `js title="x"` or a Pandoc/RMarkdown `{…}` attribute block). + // `lang` keeps just the first word for highlighting; `info` preserves + // the whole string so the fence round-trips losslessly (#4770). + info?: string; }; text: string; }