1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-24 10:27:10 +02:00

Desktop: Security: Disallow UNC file links (#9979)

This commit is contained in:
Henry Heino 2024-02-22 13:29:16 -08:00 committed by GitHub
parent 4c5e708977
commit 836e23c082
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 80 additions and 20 deletions

View File

@ -586,7 +586,11 @@ class Application extends BaseApplication {
ExternalEditWatcher.instance().setLogger(reg.logger()); ExternalEditWatcher.instance().setLogger(reg.logger());
ExternalEditWatcher.instance().initialize(bridge, this.store().dispatch); ExternalEditWatcher.instance().initialize(bridge, this.store().dispatch);
ResourceEditWatcher.instance().initialize(reg.logger(), (action: any) => { this.store().dispatch(action); }, (path: string) => bridge().openItem(path)); ResourceEditWatcher.instance().initialize(
reg.logger(),
(action: any) => { this.store().dispatch(action); },
(path: string) => bridge().openItem(path),
);
// Forwards the local event to the global event manager, so that it can // Forwards the local event to the global event manager, so that it can
// be picked up by the plugin manager. // be picked up by the plugin manager.

View File

@ -1,12 +1,15 @@
import ElectronAppWrapper from './ElectronAppWrapper'; import ElectronAppWrapper from './ElectronAppWrapper';
import shim from '@joplin/lib/shim'; import shim from '@joplin/lib/shim';
import { _, setLocale } from '@joplin/lib/locale'; import { _, setLocale } from '@joplin/lib/locale';
import { BrowserWindow, nativeTheme, nativeImage } from 'electron'; import { BrowserWindow, nativeTheme, nativeImage, shell } from 'electron';
import { dirname, isUncPath, toSystemSlashes } from '@joplin/lib/path-utils';
import { fileUriToPath } from '@joplin/utils/url';
import { urlDecode } from '@joplin/lib/string-utils';
import * as Sentry from '@sentry/electron/main'; import * as Sentry from '@sentry/electron/main';
import { writeFileSync } from 'fs';
import { homedir } from 'os'; import { homedir } from 'os';
import { msleep } from '@joplin/utils/time'; import { msleep } from '@joplin/utils/time';
const { dirname, toSystemSlashes } = require('@joplin/lib/path-utils'); import { pathExists, writeFileSync } from 'fs-extra';
import { normalize } from 'path';
interface LastSelectedPath { interface LastSelectedPath {
file: string; file: string;
@ -81,6 +84,11 @@ export class Bridge {
return this.rootProfileDir_; return this.rootProfileDir_;
} }
private logWarning(...message: string[]) {
// eslint-disable-next-line no-console
console.warn('bridge:', ...message);
}
public electronApp() { public electronApp() {
return this.electronWrapper_; return this.electronWrapper_;
} }
@ -89,6 +97,14 @@ export class Bridge {
return !this.electronApp().electronApp().isPackaged; return !this.electronApp().electronApp().isPackaged;
} }
public get autoUploadCrashDumps() {
return this.autoUploadCrashDumps_;
}
public set autoUploadCrashDumps(v: boolean) {
this.autoUploadCrashDumps_ = v;
}
public async captureException(error: any) { public async captureException(error: any) {
Sentry.captureException(error); Sentry.captureException(error);
// We wait to give the "beforeSend" event handler time to process the crash dump and write // We wait to give the "beforeSend" event handler time to process the crash dump and write
@ -300,12 +316,32 @@ export class Bridge {
return require('electron').MenuItem; return require('electron').MenuItem;
} }
public openExternal(url: string) { public async openExternal(url: string) {
return require('electron').shell.openExternal(url); const protocol = new URL(url).protocol;
if (protocol === 'file:') {
await this.openItem(url);
} else {
return shell.openExternal(url);
}
} }
public async openItem(fullPath: string) { public async openItem(fullPath: string) {
return require('electron').shell.openPath(toSystemSlashes(fullPath)); if (fullPath.startsWith('file:/')) {
fullPath = fileUriToPath(urlDecode(fullPath), shim.platformName());
}
fullPath = normalize(fullPath);
// On Windows, \\example.com\... links can map to network drives. Opening files on these
// drives can lead to arbitrary remote code execution.
const isUntrustedUncPath = isUncPath(fullPath);
if (isUntrustedUncPath) {
this.logWarning(`Not opening external file link: ${fullPath} -- it starts with two \\s, so could be to a network drive.`);
return 'Refusing to open file on a network drive.';
} else if (await pathExists(fullPath)) {
return shell.openPath(fullPath);
} else {
return 'Path does not exist.';
}
} }
public screen() { public screen() {

View File

@ -22,7 +22,7 @@ export const runtime = (): CommandRuntime => {
const { itemId, hash } = parsedUrl; const { itemId, hash } = parsedUrl;
await openItemById(itemId, context.dispatch, hash); await openItemById(itemId, context.dispatch, hash);
} else { } else {
void require('electron').shell.openExternal(link); void bridge().openExternal(link);
} }
} else if (urlProtocol(link)) { } else if (urlProtocol(link)) {
if (link.indexOf('file://') === 0) { if (link.indexOf('file://') === 0) {
@ -33,9 +33,9 @@ export const runtime = (): CommandRuntime => {
// but doesn't on macOS, so we need to convert it to a path // but doesn't on macOS, so we need to convert it to a path
// before passing it to openPath. // before passing it to openPath.
const decodedPath = fileUriToPath(urlDecode(link), shim.platformName()); const decodedPath = fileUriToPath(urlDecode(link), shim.platformName());
void require('electron').shell.openPath(decodedPath); void bridge().openItem(decodedPath);
} else { } else {
void require('electron').shell.openExternal(link); void bridge().openExternal(link);
} }
} else { } else {
bridge().showErrorMessageBox(_('Unsupported link or message: %s', link)); bridge().showErrorMessageBox(_('Unsupported link or message: %s', link));

View File

@ -15,7 +15,6 @@ import { NoteEntity, RevisionEntity } from '@joplin/lib/services/database/types'
import { AppState } from '../app.reducer'; import { AppState } from '../app.reducer';
const urlUtils = require('@joplin/lib/urlUtils'); const urlUtils = require('@joplin/lib/urlUtils');
const ReactTooltip = require('react-tooltip'); const ReactTooltip = require('react-tooltip');
const { urlDecode } = require('@joplin/lib/string-utils');
const { connect } = require('react-redux'); const { connect } = require('react-redux');
import shared from '@joplin/lib/components/shared/note-screen-shared'; import shared from '@joplin/lib/components/shared/note-screen-shared';
@ -169,11 +168,7 @@ class NoteRevisionViewerComponent extends React.PureComponent<Props, State> {
if (msg.indexOf('joplin://') === 0) { if (msg.indexOf('joplin://') === 0) {
throw new Error(_('Unsupported link or message: %s', msg)); throw new Error(_('Unsupported link or message: %s', msg));
} else if (urlUtils.urlProtocol(msg)) { } else if (urlUtils.urlProtocol(msg)) {
if (msg.indexOf('file://') === 0) { await bridge().openExternal(msg);
void require('electron').shell.openExternal(urlDecode(msg));
} else {
void require('electron').shell.openExternal(msg);
}
} else if (msg.indexOf('#') === 0) { } else if (msg.indexOf('#') === 0) {
// This is an internal anchor, which is handled by the WebView so skip this case // This is an internal anchor, which is handled by the WebView so skip this case
} else { } else {

View File

@ -4,7 +4,7 @@ import { _ } from '@joplin/lib/locale';
const { connect } = require('react-redux'); const { connect } = require('react-redux');
const { themeStyle } = require('@joplin/lib/theme'); const { themeStyle } = require('@joplin/lib/theme');
const bridge = require('@electron/remote').require('./bridge').default; import bridge from '../services/bridge';
const prettyBytes = require('pretty-bytes'); const prettyBytes = require('pretty-bytes');
import Resource from '@joplin/lib/models/Resource'; import Resource from '@joplin/lib/models/Resource';
@ -187,7 +187,7 @@ class ResourceScreenComponent extends React.Component<Props, State> {
public openResource(resource: InnerResource) { public openResource(resource: InnerResource) {
const resourcePath = Resource.fullPath(resource); const resourcePath = Resource.fullPath(resource);
const ok = bridge().openExternal(`file://${resourcePath}`); const ok = bridge().openItem(resourcePath);
if (!ok) { if (!ok) {
bridge().showErrorMessageBox(`This file could not be opened: ${resourcePath}`); bridge().showErrorMessageBox(`This file could not be opened: ${resourcePath}`);
} }

View File

@ -74,7 +74,7 @@ const textEditorCommand = () => {
export const openFileWithExternalEditor = async (filePath: string, bridge: any) => { export const openFileWithExternalEditor = async (filePath: string, bridge: any) => {
const cmd = textEditorCommand(); const cmd = textEditorCommand();
if (!cmd) { if (!cmd) {
bridge.openExternal(`file://${filePath}`); bridge.openItem(filePath);
} else { } else {
cmd.args.push(filePath); cmd.args.push(filePath);
await spawnCommand(cmd.path, cmd.args, { detached: true }); await spawnCommand(cmd.path, cmd.args, { detached: true });

View File

@ -1,4 +1,4 @@
import { extractExecutablePath, quotePath, toFileProtocolPath, unquotePath } from './path'; import { extractExecutablePath, isUncPath, quotePath, toFileProtocolPath, unquotePath } from './path';
describe('path', () => { describe('path', () => {
it('should quote and unquote paths', (async () => { it('should quote and unquote paths', (async () => {
@ -55,4 +55,22 @@ describe('path', () => {
expect(toFileProtocolPath(t[0], 'linux')).toBe(t[1]); expect(toFileProtocolPath(t[0], 'linux')).toBe(t[1]);
} }
})); }));
test.each([
['./a.txt', 'win32', false],
['./b.txt', 'win32', false],
['/home/foo/bar/baz', 'win32', false],
['./a.txt', 'posix', false],
['./b.txt', 'posix', false],
['/home/foo/bar/baz', 'posix', false],
['//LOCALHOST/', 'win32', true],
[' //LOCALHOST/', 'win32', true],
[' //example.com/a/b/c', 'win32', true],
['//LOCALHOST/', 'posix', false],
[' //example.com/a/b/c', 'posix', false],
['\\\\LOCALHOST/', 'win32', true],
])('should correctly detect UNC paths', (path, os, expected) => {
expect(isUncPath(path, os)).toBe(expected);
});
}); });

View File

@ -94,6 +94,13 @@ export function trimSlashes(path: string): string {
return ltrimSlashes(rtrimSlashes(path)); return ltrimSlashes(rtrimSlashes(path));
} }
// UNC paths can point to network drives and thus can be dangerous to open
// on some Windows devices.
// See https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dtyp/62e862f4-2a51-452e-8eeb-dc4ff5ee33cc
export const isUncPath = (path: string, os: string|null = null) => {
return toSystemSlashes(path.trim(), os).startsWith('\\\\');
};
export function quotePath(path: string) { export function quotePath(path: string) {
if (!path) return ''; if (!path) return '';
if (path.indexOf('"') < 0 && path.indexOf(' ') < 0) return path; if (path.indexOf('"') < 0 && path.indexOf(' ') < 0) return path;