1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-11-23 22:36:32 +02:00

Desktop: Resolves #11992: Multiple instances: Secure local server (#11999)

This commit is contained in:
Laurent Cozic
2025-03-25 19:48:11 +01:00
committed by GitHub
parent 0959a19d65
commit e3762dc3f8
11 changed files with 142 additions and 9 deletions

View File

@@ -1073,6 +1073,8 @@ packages/lib/fs-driver-base.js
packages/lib/fs-driver-node.js packages/lib/fs-driver-node.js
packages/lib/fsDriver.test.js packages/lib/fsDriver.test.js
packages/lib/geolocation-node.js packages/lib/geolocation-node.js
packages/lib/getAppName.test.js
packages/lib/getAppName.js
packages/lib/hooks/useAsyncEffect.js packages/lib/hooks/useAsyncEffect.js
packages/lib/hooks/useElementSize.js packages/lib/hooks/useElementSize.js
packages/lib/hooks/useEventListener.js packages/lib/hooks/useEventListener.js

2
.gitignore vendored
View File

@@ -1048,6 +1048,8 @@ packages/lib/fs-driver-base.js
packages/lib/fs-driver-node.js packages/lib/fs-driver-node.js
packages/lib/fsDriver.test.js packages/lib/fsDriver.test.js
packages/lib/geolocation-node.js packages/lib/geolocation-node.js
packages/lib/getAppName.test.js
packages/lib/getAppName.js
packages/lib/hooks/useAsyncEffect.js packages/lib/hooks/useAsyncEffect.js
packages/lib/hooks/useElementSize.js packages/lib/hooks/useElementSize.js
packages/lib/hooks/useEventListener.js packages/lib/hooks/useEventListener.js

View File

@@ -21,6 +21,8 @@ import { clearTimeout, setTimeout } from 'timers';
import { resolve } from 'path'; import { resolve } from 'path';
import { defaultWindowId } from '@joplin/lib/reducer'; import { defaultWindowId } from '@joplin/lib/reducer';
import { msleep, Second } from '@joplin/utils/time'; import { msleep, Second } from '@joplin/utils/time';
import determineBaseAppDirs from '@joplin/lib/determineBaseAppDirs';
import getAppName from '@joplin/lib/getAppName';
interface RendererProcessQuitReply { interface RendererProcessQuitReply {
canClose: boolean; canClose: boolean;
@@ -579,7 +581,11 @@ export default class ElectronAppWrapper {
if (port === null) port = this.ipcStartPort_; if (port === null) port = this.ipcStartPort_;
return await sendMessage(port, { ...message, sourcePort: this.ipcServer_.port }, { return await sendMessage(port, {
...message,
sourcePort: this.ipcServer_.port,
secretKey: this.ipcServer_.secretKey,
}, {
logger: this.ipcLogger_, logger: this.ipcLogger_,
...options, ...options,
}); });
@@ -631,6 +637,7 @@ export default class ElectronAppWrapper {
const response = await this.sendCrossAppIpcMessage({ const response = await this.sendCrossAppIpcMessage({
action: 'ping', action: 'ping',
data: null, data: null,
secretKey: this.ipcServer_.secretKey,
}, message.sourcePort, { }, message.sourcePort, {
sendToSpecificPortOnly: true, sendToSpecificPortOnly: true,
}); });
@@ -662,7 +669,12 @@ export default class ElectronAppWrapper {
}, },
}; };
this.ipcServer_ = await startServer(this.ipcStartPort_, async (message) => { const defaultProfileDir = determineBaseAppDirs('', getAppName(true, this.env() === 'dev'), '').rootProfileDir;
const secretKeyFilePath = `${defaultProfileDir}/ipc_secret_key.txt`;
this.ipcLogger_.info('Starting server using secret key:', secretKeyFilePath);
this.ipcServer_ = await startServer(this.ipcStartPort_, secretKeyFilePath, async (message) => {
if (messageHandlers[message.action]) { if (messageHandlers[message.action]) {
this.ipcLogger_.info('Got message:', message); this.ipcLogger_.info('Got message:', message);
return messageHandlers[message.action](message); return messageHandlers[message.action](message);
@@ -684,6 +696,7 @@ export default class ElectronAppWrapper {
profilePath: this.profilePath_, profilePath: this.profilePath_,
argv: process.argv, argv: process.argv,
}, },
secretKey: this.ipcServer_.secretKey,
}; };
await this.sendCrossAppIpcMessage(message); await this.sendCrossAppIpcMessage(message);

View File

@@ -65,6 +65,7 @@ import processStartFlags from './utils/processStartFlags';
import { setupAutoDeletion } from './services/trash/permanentlyDeleteOldItems'; import { setupAutoDeletion } from './services/trash/permanentlyDeleteOldItems';
import determineProfileAndBaseDir from './determineBaseAppDirs'; import determineProfileAndBaseDir from './determineBaseAppDirs';
import NavService from './services/NavService'; import NavService from './services/NavService';
import getAppName from './getAppName';
const appLogger: LoggerWrapper = Logger.create('App'); const appLogger: LoggerWrapper = Logger.create('App');
@@ -679,8 +680,7 @@ export default class BaseApplication {
let appName = options.appName; let appName = options.appName;
if (!appName) { if (!appName) {
appName = initArgs.env === 'dev' ? 'joplindev' : 'joplin'; appName = getAppName(Setting.value('appId').indexOf('-desktop') >= 0, initArgs.env === 'dev');
if (Setting.value('appId').indexOf('-desktop') >= 0) appName += '-desktop';
} }
Setting.setConstant('appName', appName); Setting.setConstant('appName', appName);

View File

@@ -0,0 +1,12 @@
import getAppName from './getAppName';
describe('getAppName', () => {
it('should get the app name', () => {
expect(getAppName(true, true)).toBe('joplindev-desktop');
expect(getAppName(true, false)).toBe('joplin-desktop');
expect(getAppName(false, false)).toBe('joplin');
expect(getAppName(false, true)).toBe('joplindev');
});
});

View File

@@ -0,0 +1,5 @@
export default (isDesktop: boolean, isDev: boolean) => {
let appName = isDev ? 'joplindev' : 'joplin';
if (isDesktop) appName += '-desktop';
return appName;
};

9
packages/utils/crypto.ts Normal file
View File

@@ -0,0 +1,9 @@
/* eslint-disable import/prefer-default-export */
import { randomBytes } from 'crypto';
export const getSecureRandomString = (length: number): string => {
const bytes = randomBytes(Math.ceil(length * 2));
const randomString = bytes.toString('base64').replace(/[^a-zA-Z0-9]/g, '');
return randomString.slice(0, length);
};

View File

@@ -1,10 +1,12 @@
/* eslint-disable import/prefer-default-export */
import { mkdirp } from 'fs-extra'; import { mkdirp } from 'fs-extra';
import { FileLocker } from './fs'; import { FileLocker } from './fs';
import { msleep, Second } from './time'; import { msleep, Second } from './time';
const baseTempDir = `${__dirname}/../app-cli/tests/tmp`; const baseTempDir = `${__dirname}/../app-cli/tests/tmp`;
const createTempDir = async () => { export const createTempDir = async () => {
const p = `${baseTempDir}/${Date.now()}`; const p = `${baseTempDir}/${Date.now()}`;
await mkdirp(p); await mkdirp(p);
return p; return p;

View File

@@ -1,11 +1,15 @@
import { readFile } from 'fs/promises';
import { createTempDir } from './fs.test';
import { newHttpError, sendMessage, startServer, stopServer } from './ipc'; import { newHttpError, sendMessage, startServer, stopServer } from './ipc';
describe('ipc', () => { describe('ipc', () => {
it('should send and receive messages', async () => { it('should send and receive messages', async () => {
const tempDir = await createTempDir();
const secretFilePath = `${tempDir}/secret.txt`;
const startPort = 41168; const startPort = 41168;
const server1 = await startServer(startPort, async (request) => { const server1 = await startServer(startPort, secretFilePath, async (request) => {
if (request.action === 'testing') { if (request.action === 'testing') {
return { return {
text: 'hello1', text: 'hello1',
@@ -15,7 +19,7 @@ describe('ipc', () => {
throw newHttpError(404); throw newHttpError(404);
}); });
const server2 = await startServer(startPort, async (request) => { const server2 = await startServer(startPort, secretFilePath, async (request) => {
if (request.action === 'testing') { if (request.action === 'testing') {
return { return {
text: 'hello2', text: 'hello2',
@@ -31,12 +35,15 @@ describe('ipc', () => {
throw newHttpError(404); throw newHttpError(404);
}); });
const secretKey = await readFile(secretFilePath, 'utf-8');
{ {
const responses = await sendMessage(startPort, { const responses = await sendMessage(startPort, {
action: 'testing', action: 'testing',
data: { data: {
test: 1234, test: 1234,
}, },
secretKey,
}); });
expect(responses).toEqual([ expect(responses).toEqual([
@@ -49,6 +56,7 @@ describe('ipc', () => {
const responses = await sendMessage(startPort, { const responses = await sendMessage(startPort, {
action: 'ping', action: 'ping',
data: null, data: null,
secretKey,
}); });
expect(responses).toEqual([ expect(responses).toEqual([
@@ -63,6 +71,7 @@ describe('ipc', () => {
test: 1234, test: 1234,
}, },
sourcePort: 41168, sourcePort: 41168,
secretKey,
}); });
expect(responses).toEqual([ expect(responses).toEqual([
@@ -74,4 +83,44 @@ describe('ipc', () => {
await stopServer(server2); await stopServer(server2);
}); });
it('should not process message if secret is invalid', async () => {
const tempDir = await createTempDir();
const secretFilePath = `${tempDir}/secret.txt`;
const startPort = 41168;
const server = await startServer(startPort, secretFilePath, async (request) => {
if (request.action === 'testing') {
return {
text: 'hello1',
};
}
throw newHttpError(404);
});
const secretKey = await readFile(secretFilePath, 'utf-8');
{
const responses = await sendMessage(startPort, {
action: 'testing',
data: null,
secretKey: 'wrong_key',
});
expect(responses.length).toBe(0);
}
{
const responses = await sendMessage(startPort, {
action: 'testing',
data: null,
secretKey,
});
expect(responses.length).toBe(1);
}
await stopServer(server);
});
}); });

View File

@@ -2,6 +2,9 @@ import { createServer, IncomingMessage, ServerResponse } from 'http';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import { Server } from 'http'; import { Server } from 'http';
import Logger from './Logger'; import Logger from './Logger';
import { pathExists } from 'fs-extra';
import { readFile, writeFile } from 'fs/promises';
import { getSecureRandomString } from './crypto';
const tcpPortUsed = require('tcp-port-used'); const tcpPortUsed = require('tcp-port-used');
const maxPorts = 10; const maxPorts = 10;
@@ -51,6 +54,7 @@ export interface Message {
action: string; action: string;
data: object|number|string|null; data: object|number|string|null;
sourcePort?: number; sourcePort?: number;
secretKey?: string;
} }
type Response = string|number|object|boolean; type Response = string|number|object|boolean;
@@ -66,21 +70,52 @@ export type IpcMessageHandler = (message: Message)=> Promise<Response|void>;
export interface IpcServer { export interface IpcServer {
port: number; port: number;
httpServer: Server; httpServer: Server;
secretKey: string;
} }
interface StartServerOptions { interface StartServerOptions {
logger?: Logger; logger?: Logger;
} }
export const startServer = async (startPort: number, messageHandler: IpcMessageHandler, options: StartServerOptions|null = null): Promise<IpcServer> => { const getSecretKey = async (filePath: string) => {
try {
const keyLength = 64;
const writeKeyToFile = async () => {
const key = getSecureRandomString(keyLength);
await writeFile(filePath, key, 'utf-8');
return key;
};
if (!(await pathExists(filePath))) {
return await writeKeyToFile();
}
const key = await readFile(filePath, 'utf-8');
if (key.length !== keyLength) return await writeKeyToFile();
return key;
} catch (error) {
const e = error as NodeJS.ErrnoException;
e.message = `Could not get secret key from file: ${filePath}`;
throw e;
}
};
// `secretKeyFilePath` must be the same for all the instances that can communicate with each others
export const startServer = async (startPort: number, secretKeyFilePath: string, messageHandler: IpcMessageHandler, options: StartServerOptions|null = null): Promise<IpcServer> => {
const port = await findAvailablePort(startPort); const port = await findAvailablePort(startPort);
const logger = options && options.logger ? options.logger : new Logger(); const logger = options && options.logger ? options.logger : new Logger();
const secretKey = await getSecretKey(secretKeyFilePath);
return new Promise<IpcServer>((resolve, reject) => { return new Promise<IpcServer>((resolve, reject) => {
try { try {
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
let message: Message|null = null;
try { try {
const message = await parseJson(req) as Message; message = await parseJson(req) as Message;
if (message.secretKey !== secretKey) throw newHttpError(401, 'Invalid secret key');
if (!message.action) throw newHttpError(400, 'Missing "action" property in message'); if (!message.action) throw newHttpError(400, 'Missing "action" property in message');
const response = await messageHandler(message); const response = await messageHandler(message);
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -88,6 +123,7 @@ export const startServer = async (startPort: number, messageHandler: IpcMessageH
} catch (error) { } catch (error) {
const httpError = error as HttpError; const httpError = error as HttpError;
const httpCode = httpError.httpCode || 500; const httpCode = httpError.httpCode || 500;
logger.error('Could not response to request:', message, 'Error', httpCode, httpError.message);
res.writeHead(httpCode, { 'Content-Type': 'text/plain' }); res.writeHead(httpCode, { 'Content-Type': 'text/plain' });
res.end(`Error ${httpCode}: ${httpError.message}`); res.end(`Error ${httpCode}: ${httpError.message}`);
} }
@@ -101,6 +137,7 @@ export const startServer = async (startPort: number, messageHandler: IpcMessageH
resolve({ resolve({
httpServer: server, httpServer: server,
port, port,
secretKey,
}); });
}); });
} catch (error) { } catch (error) {

View File

@@ -14,6 +14,8 @@ Joplin Desktop supports multiple instances through **profile locking** and **IPC
- When a message is sent, the implementation automatically discovers running IPC servers. - When a message is sent, the implementation automatically discovers running IPC servers.
- Messages are secured using a secret that is shared by all applications. That secret is read from the profile directory of the main instance. It is created by the server if it doesn't exist. This ensures that, for example, a browser cannot send a valid message to the apps.
### Instance Differentiation ### Instance Differentiation
- The `--alt-instance-id` flag must be used to launch an alternative instance. This disables services like the Web Clipper. - The `--alt-instance-id` flag must be used to launch an alternative instance. This disables services like the Web Clipper.