Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions packages/desktop/src/common/filesystem/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
21 changes: 19 additions & 2 deletions packages/desktop/src/main/menu/actions/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
} 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'
Expand Down Expand Up @@ -118,12 +118,12 @@
Object.assign(options, getPdfPageOptions(pageOptions))
const data = await win.webContents.printToPDF(options)
removePrintServiceFromWindow(win)
await writeFile(filePath, data, extension!, 'binary')

Check warning on line 121 in packages/desktop/src/main/menu/actions/file.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion
} else {
if (!content) {
throw new Error('No HTML content found.')
}
await writeFile(filePath, content, extension!, 'utf8')

Check warning on line 126 in packages/desktop/src/main/menu/actions/file.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion
}
win.webContents.send('mt::export-success', { type, filePath })
} catch (err) {
Expand Down Expand Up @@ -209,7 +209,7 @@
ipcMain.emit('window-add-file-path', win.id, filePath)
ipcMain.emit('menu-add-recently-used', filePath)

const newFilename = path.basename(filePath!)

Check warning on line 212 in packages/desktop/src/main/menu/actions/file.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion
win.webContents.send('mt::set-pathname', { id, pathname: filePath, filename: newFilename })
} else {
ipcMain.emit('window-file-saved', win.id, filePath)
Expand Down Expand Up @@ -374,7 +374,7 @@
ipcMain.emit('window-add-file-path', win.id, filePath)
ipcMain.emit('menu-add-recently-used', filePath)

const newFilename = path.basename(filePath!)

Check warning on line 377 in packages/desktop/src/main/menu/actions/file.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion
win.webContents.send('mt::set-pathname', {
id,
pathname: filePath,
Expand Down Expand Up @@ -583,7 +583,7 @@
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
}
Expand Down Expand Up @@ -629,6 +629,23 @@
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)
}
}
Expand Down
6 changes: 5 additions & 1 deletion packages/desktop/static/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion packages/desktop/static/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion packages/desktop/static/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion packages/desktop/static/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion packages/desktop/static/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -889,7 +889,11 @@
"replace": "置換",
"save": "保存",
"saveChanges": "変更を保存",
"saveFailure": "保存に失敗"
"saveFailure": "保存に失敗",
"unsafeFileTitle": "安全でない可能性のあるファイル",
"unsafeFileMessage": "このリンクはコードを実行できるファイルを開きます",
"unsafeFileDetail": "「{name}」は実行可能ファイルまたはスクリプトファイルです。開くとコンピューター上でプログラムが実行される可能性があります。この文書を信頼できる場合のみ続行してください。",
"openAnyway": "それでも開く"
},
"error": {
"configSchemaViolation": "設定スキーマ違反",
Expand Down
6 changes: 5 additions & 1 deletion packages/desktop/static/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -889,7 +889,11 @@
"replace": "바꾸기",
"save": "저장",
"saveChanges": "변경 사항 저장",
"saveFailure": "저장 실패"
"saveFailure": "저장 실패",
"unsafeFileTitle": "안전하지 않을 수 있는 파일",
"unsafeFileMessage": "이 링크는 코드를 실행할 수 있는 파일을 엽니다",
"unsafeFileDetail": "\"{name}\"은(는) 실행 파일 또는 스크립트 파일입니다. 열면 컴퓨터에서 프로그램이 실행될 수 있습니다. 이 문서를 신뢰하는 경우에만 계속하세요.",
"openAnyway": "그래도 열기"
},
"error": {
"configSchemaViolation": "구성 스키마 위반",
Expand Down
6 changes: 5 additions & 1 deletion packages/desktop/static/locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion packages/desktop/static/locales/tr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion packages/desktop/static/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -889,7 +889,11 @@
"replace": "替换",
"save": "保存",
"saveChanges": "保存修改",
"saveFailure": "保存失败"
"saveFailure": "保存失败",
"unsafeFileTitle": "潜在不安全的文件",
"unsafeFileMessage": "此链接将打开一个可执行代码的文件",
"unsafeFileDetail": "“{name}”是可执行文件或脚本文件。打开它可能会在你的计算机上运行程序。请仅在信任此文档时继续。",
"openAnyway": "仍然打开"
},
"error": {
"configSchemaViolation": "配置架构违规",
Expand Down
6 changes: 5 additions & 1 deletion packages/desktop/static/locales/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -889,7 +889,11 @@
"replace": "取代",
"save": "儲存",
"saveChanges": "儲存變更",
"saveFailure": "儲存失敗"
"saveFailure": "儲存失敗",
"unsafeFileTitle": "潛在不安全的檔案",
"unsafeFileMessage": "此連結將開啟一個可執行程式碼的檔案",
"unsafeFileDetail": "「{name}」是可執行檔或指令碼檔案。開啟它可能會在你的電腦上執行程式。請僅在信任此文件時繼續。",
"openAnyway": "仍要開啟"
},
"error": {
"configSchemaViolation": "設定結構違規",
Expand Down
62 changes: 62 additions & 0 deletions packages/desktop/test/unit/specs/dangerous-executable-file.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
16 changes: 16 additions & 0 deletions packages/muya/src/assets/styles/exportStyle.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<br>`, 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 `<li>`. Loose items wrap their content in
`<p>` (handled by the `p` rule); giving them `li` pre-wrap would expose
marked's pretty-printing newline between `</p>` and `</li>` 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;
}
Expand Down
18 changes: 18 additions & 0 deletions packages/muya/src/clipboard/__tests__/pasteListMerge.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
});
});
Loading
Loading