Desktop: Security: Strengthen Content-Security-Policy (#14316)

This commit is contained in:
Henry Heino authored and GitHub committed 2026-02-10 10:15:13 +00:00
1 parent 14284be1c2
commit e3028e39fe
19 files changed
+427 -121

No files matched your search

+3 -1
View File
@@ -269,6 +269,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/useEditorCommands.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/localisation.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useContentScriptRegistration.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useKeymap.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useRefocusOnVisiblePaneChange.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useSyncEditorValue.js
@@ -623,7 +624,8 @@ packages/app-desktop/utils/checkForUpdatesUtils.test.js
packages/app-desktop/utils/checkForUpdatesUtils.js
packages/app-desktop/utils/checkForUpdatesUtilsTestData.js
packages/app-desktop/utils/customProtocols/constants.js
packages/app-desktop/utils/customProtocols/handleCustomProtocols.test.js
packages/app-desktop/utils/customProtocols/handleCustomProtocols.content.test.js
packages/app-desktop/utils/customProtocols/handleCustomProtocols.plugins.test.js
packages/app-desktop/utils/customProtocols/handleCustomProtocols.js
packages/app-desktop/utils/customProtocols/registerCustomProtocols.js
packages/app-desktop/utils/getAssetPath.js
+3 -1
View File
@@ -242,6 +242,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/Editor.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/useEditorCommands.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/localisation.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useContentScriptRegistration.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useKeymap.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useRefocusOnVisiblePaneChange.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useSyncEditorValue.js
@@ -596,7 +597,8 @@ packages/app-desktop/utils/checkForUpdatesUtils.test.js
packages/app-desktop/utils/checkForUpdatesUtils.js
packages/app-desktop/utils/checkForUpdatesUtilsTestData.js
packages/app-desktop/utils/customProtocols/constants.js
packages/app-desktop/utils/customProtocols/handleCustomProtocols.test.js
packages/app-desktop/utils/customProtocols/handleCustomProtocols.content.test.js
packages/app-desktop/utils/customProtocols/handleCustomProtocols.plugins.test.js
packages/app-desktop/utils/customProtocols/handleCustomProtocols.js
packages/app-desktop/utils/customProtocols/registerCustomProtocols.js
packages/app-desktop/utils/getAssetPath.js
+9 -5
View File
@@ -16,7 +16,7 @@ const fs = require('fs-extra');
import { dialog, ipcMain } from 'electron';
import { _ } from '@joplin/lib/locale';
import restartInSafeModeFromMain from './utils/restartInSafeModeFromMain';
import handleCustomProtocols, { CustomProtocolHandler } from './utils/customProtocols/handleCustomProtocols';
import handleCustomProtocols, { CustomProtocolHandlers } from './utils/customProtocols/handleCustomProtocols';
import { clearTimeout, setTimeout } from 'timers';
import { resolve } from 'path';
import { defaultWindowId } from '@joplin/lib/reducer';
@@ -68,7 +68,7 @@ export default class ElectronAppWrapper {
private initialCallbackUrl_: string = null;
private updaterService_: AutoUpdaterService = null;
private customProtocolHandler_: CustomProtocolHandler = null;
private customProtocolHandlers_: CustomProtocolHandlers|null = null;
private updatePollInterval_: ReturnType<typeof setTimeout>|null = null;
private profileLocker_: FileLocker|null = null;
@@ -816,8 +816,12 @@ export default class ElectronAppWrapper {
}
};
public getCustomProtocolHandler() {
return this.customProtocolHandler_;
public getContentProtocolHandler() {
return this.customProtocolHandlers_.appContent;
}
public getPluginProtocolHandler() {
return this.customProtocolHandlers_.pluginContent;
}
private async fixLinuxAccessibility_() {
@@ -857,7 +861,7 @@ export default class ElectronAppWrapper {
await this.fixLinuxAccessibility_();
this.customProtocolHandler_ = handleCustomProtocols();
this.customProtocolHandlers_ = handleCustomProtocols();
this.createWindow();
this.electronApp_.on('before-quit', () => {
+4 -4
View File
@@ -58,7 +58,7 @@ import OcrDriverTesseract from '@joplin/lib/services/ocr/drivers/OcrDriverTesser
import OcrDriverTranscribe from '@joplin/lib/services/ocr/drivers/OcrDriverTranscribe';
import SearchEngine from '@joplin/lib/services/search/SearchEngine';
import { PackageInfo } from '@joplin/lib/versionInfo';
import { CustomProtocolHandler } from './utils/customProtocols/handleCustomProtocols';
import { CustomContentProtocolHandler } from './utils/customProtocols/handleCustomProtocols';
import { refreshFolders } from '@joplin/lib/folders-screen-utils';
import initializeCommandService from './utils/initializeCommandService';
import OcrDriverBase from '@joplin/lib/services/ocr/OcrDriverBase';
@@ -82,7 +82,7 @@ class Application extends BaseApplication {
private checkAllPluginStartedIID_: any = null;
private initPluginServiceDone_ = false;
private ocrService_: OcrService;
private protocolHandler_: CustomProtocolHandler;
private protocolHandler_: CustomContentProtocolHandler;
public constructor() {
super();
@@ -130,7 +130,7 @@ class Application extends BaseApplication {
}
if (action.type === 'SETTING_UPDATE_ONE' && action.key === 'renderer.fileUrls' || action.type === 'SETTING_UPDATE_ALL') {
bridge().electronApp().getCustomProtocolHandler().setMediaAccessEnabled(
bridge().electronApp().getContentProtocolHandler().setMediaAccessEnabled(
Setting.value('renderer.fileUrls'),
);
}
@@ -477,7 +477,7 @@ class Application extends BaseApplication {
}
addTask('app/set up custom protocol handler', async () => {
this.protocolHandler_ = bridge().electronApp().getCustomProtocolHandler();
this.protocolHandler_ = bridge().electronApp().getContentProtocolHandler();
this.protocolHandler_.allowReadAccessToDirectory(__dirname); // App bundle directory
this.protocolHandler_.allowReadAccessToDirectory(Setting.value('cacheDir'));
this.protocolHandler_.allowReadAccessToDirectory(Setting.value('resourceDir'));
@@ -1,15 +1,11 @@
import * as React from 'react';
import { ForwardedRef, RefObject } from 'react';
import { useEffect, useState, useRef, forwardRef, useImperativeHandle } from 'react';
import { EditorProps, LogMessageCallback, OnEventCallback, ContentScriptData } from '@joplin/editor/types';
import { EditorProps, LogMessageCallback, OnEventCallback } from '@joplin/editor/types';
import createEditor from '@joplin/editor/CodeMirror/createEditor';
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import { ContentScriptType } from '@joplin/lib/services/plugins/api/types';
import shim from '@joplin/lib/shim';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import setupVim from '@joplin/editor/CodeMirror/utils/setupVim';
import { dirname } from 'path';
import useKeymap from './utils/useKeymap';
import CommandService from '@joplin/lib/services/CommandService';
import { SearchMarkers } from '../../../utils/useSearchMarkers';
@@ -18,6 +14,7 @@ import Resource from '@joplin/lib/models/Resource';
import { parseResourceUrl } from '@joplin/lib/urlUtils';
import { resourceFilename } from '@joplin/lib/models/utils/resourceUtils';
import getResourceBaseUrl from '../../../utils/getResourceBaseUrl';
import useContentScriptRegistration from './utils/useContentScriptRegistration';
interface Props extends EditorProps {
style: React.CSSProperties;
@@ -64,37 +61,7 @@ const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => {
return editor;
}, [editor]);
useEffect(() => {
if (!editor) {
return;
}
const contentScripts: ContentScriptData[] = [];
for (const pluginId in props.pluginStates) {
const pluginState = props.pluginStates[pluginId];
const codeMirrorContentScripts = pluginState.contentScripts[ContentScriptType.CodeMirrorPlugin] ?? [];
for (const contentScript of codeMirrorContentScripts) {
contentScripts.push({
pluginId,
contentScriptId: contentScript.id,
contentScriptJs: () => shim.fsDriver().readFile(contentScript.path),
loadCssAsset: (name: string) => {
const assetPath = dirname(contentScript.path);
const path = shim.fsDriver().resolveRelativePathWithinDir(assetPath, name);
return shim.fsDriver().readFile(path, 'utf8');
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
postMessageHandler: (message: any) => {
const plugin = PluginService.instance().pluginById(pluginId);
return plugin.emitContentScriptMessage(contentScript.id, message);
},
});
}
}
void editor.setContentScripts(contentScripts);
}, [editor, props.pluginStates]);
useContentScriptRegistration({ editor, pluginStates: props.pluginStates });
useEffect(() => {
if (!editorContainerRef.current) return () => {};
@@ -0,0 +1,99 @@
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
import { ContentScriptData, ContentScriptLoadOptions } from '@joplin/editor/types';
import { ContentScriptType } from '@joplin/lib/services/plugins/api/types';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import shim from '@joplin/lib/shim';
import { dirname } from 'path';
import { useEffect, useId, useRef } from 'react';
import bridge from '../../../../../../services/bridge';
import type { ContentScriptRegistration } from '../../../../../../utils/customProtocols/handleCustomProtocols';
interface Props {
editor: CodeMirrorControl;
pluginStates: PluginStates;
}
const useContentScriptRegistration = ({ editor, pluginStates }: Props) => {
const loadedContentScriptRefs = useRef(new Map<string, ContentScriptRegistration>());
const editorId = useId();
useEffect(() => {
if (!editor) {
return;
}
const contentScripts: ContentScriptData[] = [];
for (const pluginId in pluginStates) {
const pluginState = pluginStates[pluginId];
const codeMirrorContentScripts = pluginState.contentScripts[ContentScriptType.CodeMirrorPlugin] ?? [];
for (const contentScript of codeMirrorContentScripts) {
// Ensure that the key is unique to the (pluginId, editorId, contentScript) set.
// Include the plugin ID to prevent ID collisions if multiple plugins register
// content scripts with the same ID:
const scriptId = `${pluginId}::${contentScript.id}`;
loadedContentScriptRefs.current.get(scriptId)?.revoke();
contentScripts.push({
pluginId,
contentScriptId: contentScript.id,
contentScriptJs: async (context) => {
const handle = await registerContentScriptWithMainProcess({
scriptPath: contentScript.path,
context,
key: `${editorId}::${scriptId}`,
});
loadedContentScriptRefs.current.set(scriptId, handle);
return { uri: handle.uri };
},
loadCssAsset: (name: string) => {
const assetPath = dirname(contentScript.path);
const path = shim.fsDriver().resolveRelativePathWithinDir(assetPath, name);
return shim.fsDriver().readFile(path, 'utf8');
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
postMessageHandler: (message: any) => {
const plugin = PluginService.instance().pluginById(pluginId);
return plugin.emitContentScriptMessage(contentScript.id, message);
},
});
}
}
void editor.setContentScripts(contentScripts);
}, [editor, pluginStates, editorId]);
useEffect(() => () => {
for (const script of loadedContentScriptRefs.current.values()) {
script.revoke();
}
loadedContentScriptRefs.current.clear();
}, []);
};
interface RegisterContentScriptOptions {
key: string; // A unique identifier for the content script
scriptPath: string;
context: ContentScriptLoadOptions;
}
const registerContentScriptWithMainProcess = async (
{ key, scriptPath, context }: RegisterContentScriptOptions,
) => {
const contentScriptJs = [
context.contentScriptStartJs,
await shim.fsDriver().readFile(scriptPath),
context.contentScriptEndJs,
].join('\n');
const content = bridge().electronApp().getPluginProtocolHandler().registerContentScript(
encodeURIComponent(key),
contentScriptJs,
);
return content;
};
export default useContentScriptRegistration;
+1 -1
View File
@@ -86,7 +86,7 @@ const NoteTextViewer = forwardRef((props: Props, ref: ForwardedRef<NoteViewerCon
const result: NoteViewerControl = {
domReady: () => domReadyRef.current,
setHtml: (html: string, options: SetHtmlOptions) => {
const protocolHandler = bridge().electronApp().getCustomProtocolHandler();
const protocolHandler = bridge().electronApp().getContentProtocolHandler();
// Grant & remove asset access.
if (options.pluginAssets) {
+1 -1
View File
@@ -9,7 +9,7 @@
connect-src 'self' * http://* https://* joplin-content://* blob: ;
style-src 'unsafe-inline' 'self' blob: joplin-content://* https://* http://* ;
child-src 'self' joplin-content://* https://*.youtube.com https://*.youtube-nocookie.com ;
script-src 'self' 'unsafe-inline' joplin-content://* ;
script-src 'self' joplin-plugin://* joplin-content://* ;
media-src 'self' * blob: data: https://* http://* joplin-content://* ;
img-src 'self' blob: data: http://* https://* joplin-content://* ;
font-src 'self' http://* https://* blob: data: joplin-content://* ;
@@ -122,14 +122,16 @@ function UserWebview(props: Props, ref: any) {
} as React.CSSProperties), [contentSize.width, contentSize.height]);
const src = useMemo(() => {
const isolate = Setting.value('featureFlag.plugins.isolatePluginWebViews');
let isolate = Setting.value('featureFlag.plugins.isolatePluginWebViews');
isolate ||= needsIsolation(props.pluginId);
const path = toForwardSlashes(getAssetPath('services/plugins/UserWebviewIndex.html'));
if (isolate) {
return `joplin-content://plugin-webview/${path}`;
} else {
return `file://${path}`;
}
}, []);
}, [props.pluginId]);
return <iframe
id={props.viewId}
@@ -141,3 +143,9 @@ function UserWebview(props: Props, ref: any) {
}
export default forwardRef(UserWebview);
const needsIsolation = (pluginId: string) => {
// Some plugins are broken unless isolated from the main application.
// Always enable isolation for these plugins, even if disabled in settings:
return ['joplin.plugin.note.tabs', 'joplin.plugin.benji.favorites', 'outline'].includes(pluginId);
};
@@ -4,7 +4,7 @@ import bridge from '../../bridge';
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
export default function(postMessage: Function, isReady: boolean, scripts: string[], cssFilePath: string) {
const protocolHandler = useMemo(() => {
return bridge().electronApp().getCustomProtocolHandler();
return bridge().electronApp().getContentProtocolHandler();
}, []);
useEffect(() => {
@@ -1,2 +1,7 @@
// eslint-disable-next-line import/prefer-default-export
// Protocol related to note content (e.g. attachments,
// the note viewer, etc.)
export const contentProtocolName = 'joplin-content';
// Protocol related to serving plugin content (e.g. editor content
// scripts).
export const pluginProtocolName = 'joplin-plugin';
@@ -28,16 +28,23 @@ import { toForwardSlashes } from '@joplin/utils/path';
const setUpProtocolHandler = () => {
const protocolHandler = handleCustomProtocols();
expect(handleProtocolMock).toHaveBeenCalledTimes(1);
expect(handleProtocolMock).toHaveBeenCalled();
// Should have registered the protocol.
const lastCallArgs = handleProtocolMock.mock.lastCall;
expect(lastCallArgs[0]).toBe('joplin-content');
let onRequestListener;
for (const call of handleProtocolMock.mock.calls) {
if (call[0] === 'joplin-content') {
// The request listener is the second argument:
onRequestListener = call[1];
}
}
// Extract the request listener so that it can be called by our tests.
const onRequestListener = lastCallArgs[1];
// Should have registered the protocol
expect(onRequestListener).toBeDefined();
return { protocolHandler, onRequestListener };
return {
appProtocolHandler: protocolHandler.appContent,
onRequestListener,
};
};
interface ExpectBlockedOptions {
@@ -67,7 +74,7 @@ const expectPathToBeUnblocked = async (onRequestListener: ProtocolHandler, fileP
};
describe('handleCustomProtocols', () => {
describe('handleCustomProtocols.content', () => {
beforeEach(() => {
// Reset mocks between tests to ensure a clean testing environment.
customProtocols.clear();
@@ -76,7 +83,7 @@ describe('handleCustomProtocols', () => {
});
test('should only allow access to files in allowed directories', async () => {
const { protocolHandler, onRequestListener } = setUpProtocolHandler();
const { appProtocolHandler: protocolHandler, onRequestListener } = setUpProtocolHandler();
await expectPathToBeBlocked(onRequestListener, '/test/path');
await expectPathToBeBlocked(onRequestListener, '/');
@@ -98,7 +105,7 @@ describe('handleCustomProtocols', () => {
});
test('should be possible to allow and remove read access for a file', async () => {
const { protocolHandler, onRequestListener } = setUpProtocolHandler();
const { appProtocolHandler: protocolHandler, onRequestListener } = setUpProtocolHandler();
await expectPathToBeBlocked(onRequestListener, '/test/path/a.txt');
const handle1 = protocolHandler.allowReadAccessToFile('/test/path/a.txt');
@@ -113,7 +120,7 @@ describe('handleCustomProtocols', () => {
});
test('should only allow access to file-media/ URLs when given the correct access key', async () => {
const { protocolHandler, onRequestListener } = setUpProtocolHandler();
const { appProtocolHandler: protocolHandler, onRequestListener } = setUpProtocolHandler();
const expectBlocked = (path: string) => {
return expectPathToBeBlocked(onRequestListener, path, { host: 'file-media' });
};
@@ -141,7 +148,7 @@ describe('handleCustomProtocols', () => {
});
test('should allow requesting part of a file', async () => {
const { protocolHandler, onRequestListener } = setUpProtocolHandler();
const { appProtocolHandler: protocolHandler, onRequestListener } = setUpProtocolHandler();
protocolHandler.allowReadAccessToDirectory(`${supportDir}/`);
const targetFilePath = join(supportDir, 'photo.jpg');
@@ -0,0 +1,82 @@
/** @jest-environment node */
type ProtocolHandler = (request: Request)=> Promise<Response>;
const customProtocols: Map<string, ProtocolHandler> = new Map();
const handleProtocolMock = jest.fn((scheme: string, handler: ProtocolHandler) => {
customProtocols.set(scheme, handler);
});
jest.doMock('electron', () => {
return {
protocol: {
handle: handleProtocolMock,
},
};
});
import handleCustomProtocols from './handleCustomProtocols';
const setUpProtocolHandler = () => {
const protocolHandler = handleCustomProtocols();
expect(handleProtocolMock).toHaveBeenCalled();
let onRequestListener;
for (const call of handleProtocolMock.mock.calls) {
if (call[0] === 'joplin-plugin') {
onRequestListener = call[1];
}
}
// Should have registered the protocol
expect(onRequestListener).toBeDefined();
return {
protocolHandler: protocolHandler.pluginContent,
onRequestListener,
};
};
describe('handleCustomProtocols.plugins', () => {
beforeEach(() => {
// Reset mocks between tests to ensure a clean testing environment.
customProtocols.clear();
handleProtocolMock.mockClear();
});
test('should return a 404 error response for unregistered content script paths', async () => {
const { onRequestListener } = setUpProtocolHandler();
const response = await onRequestListener(new Request('joplin-plugin://plugins/testing'));
expect(response.status).toBe(404);
});
test('should return a 405 method not supported response for non-get requests', async () => {
const { onRequestListener } = setUpProtocolHandler();
const response = await onRequestListener(new Request('joplin-plugin://plugins/testing', { method: 'POST' }));
expect(response.status).toBe(405);
});
test('should return a content script', async () => {
const { protocolHandler, onRequestListener } = setUpProtocolHandler();
const handle = protocolHandler.registerContentScript('some-id-here', 'test()');
const response = await onRequestListener(new Request(handle.uri));
expect(response.status).toBe(200);
expect(await response.text()).toBe('test()');
expect(response.headers.get('Content-Type')).toBe('application/javascript');
});
test('should return a 404 error after a content script has been removed', async () => {
const { protocolHandler, onRequestListener } = setUpProtocolHandler();
const handle = protocolHandler.registerContentScript('some-id-here', 'test()');
handle.revoke();
const response = await onRequestListener(new Request(handle.uri));
expect(response.status).toBe(404);
});
});
@@ -1,7 +1,7 @@
import { net, protocol } from 'electron';
import { dirname, resolve, normalize } from 'path';
import { fileURLToPath, pathToFileURL } from 'url';
import { contentProtocolName } from './constants';
import { contentProtocolName, pluginProtocolName } from './constants';
import resolvePathWithinDir from '@joplin/lib/utils/resolvePathWithinDir';
import * as fs from 'fs-extra';
import { createReadStream } from 'fs';
@@ -12,7 +12,7 @@ export interface AccessController {
remove(): void;
}
export interface CustomProtocolHandler {
export interface CustomContentProtocolHandler {
// note-viewer/ URLs
allowReadAccessToDirectory(path: string): void;
allowReadAccessToFile(path: string): AccessController;
@@ -23,6 +23,20 @@ export interface CustomProtocolHandler {
getMediaAccessKey(): string;
}
export interface ContentScriptRegistration {
uri: string;
revoke: ()=> void;
}
export interface CustomPluginProtocolHandler {
registerContentScript(id: string, js: string): ContentScriptRegistration;
}
export interface CustomProtocolHandlers {
appContent: CustomContentProtocolHandler;
pluginContent: CustomPluginProtocolHandler;
}
// In some cases, the NodeJS built-in adapter (Readable.toWeb) closes its controller twice,
// leading to an error dialog. See:
@@ -136,29 +150,20 @@ const makeNotFoundResponse = () => {
});
};
// Creating a custom protocol allows us to isolate iframes by giving them
// different domain names from the main Joplin app.
//
// For example, an iframe with url joplin-content://note-viewer/path/to/iframe.html will run
// in a different process from a parent frame with url file://path/to/iframe.html.
//
// See note_viewer_isolation.md for why this is important.
//
// TODO: Use Logger.create (doesn't work for now because Logger is only initialized
// in the main process.)
const handleCustomProtocols = (): CustomProtocolHandler => {
const logger = {
// Disabled for now
debug: (..._message: unknown[]) => {},
};
type LoggerSlice = {
debug: (...message: unknown[])=> void;
};
const handleContentProtocol = (logger: LoggerSlice) => {
// Allow-listed files/directories for joplin-content://note-viewer/
const readableDirectories: string[] = [];
const readableFiles = new Map<string, number>();
// Access for joplin-content://file-media/
let mediaAccessKey: string|false = false;
// See also the protocol.handle example: https://www.electronjs.org/docs/latest/api/protocol#protocolhandlescheme-handler
const appBundleDirectory = dirname(dirname(__dirname));
// Serves main app content (e.g. the note viewer)
protocol.handle(contentProtocolName, async request => {
const url = new URL(request.url);
const host = url.host;
@@ -251,8 +256,7 @@ const handleCustomProtocols = (): CustomProtocolHandler => {
return response;
});
const appBundleDirectory = dirname(dirname(__dirname));
const result: CustomProtocolHandler = {
const result: CustomContentProtocolHandler = {
allowReadAccessToDirectory: (path: string) => {
path = resolve(appBundleDirectory, path);
logger.debug('protocol handler: Allow read access to directory', path);
@@ -308,4 +312,84 @@ const handleCustomProtocols = (): CustomProtocolHandler => {
return result;
};
const handlePluginProtocol = (logger: LoggerSlice) => {
const hostedContentScriptJs = new Map<string, string>(); // Maps from content script IDs to content script data
protocol.handle(pluginProtocolName, async request => {
const url = new URL(request.url);
const host = url.host;
if (host !== 'plugins') {
return new Response('Unknown hostname', { status: 400 });
}
if (request.method !== 'GET') {
return new Response('Unsupported request method', { status: 405 });
}
const contentScriptPath = '/content-script/';
if (url.pathname.startsWith(contentScriptPath)) {
const contentScriptId = url.pathname.substring(contentScriptPath.length);
logger.debug('Request for content script with ID', contentScriptId);
const js = hostedContentScriptJs.get(contentScriptId);
if (!js) {
return new Response('Content script not found', { status: 404 });
}
return new Response(js, {
headers: [
['Content-Type', 'application/javascript'],
],
});
} else {
return new Response('Path not found', { status: 404 });
}
});
const handler: CustomPluginProtocolHandler = {
// Hosts a content script with the given `js` using the plugin protocol.
// This can be used to allow loading trusted plugin JavaScript without relying on eval,
// inline scripts, or other techniques that would violate the application's
// Content-Security-Policy.
//
// Caution: This assumes that `js` is provided by a trusted source. Be careful
// when building/providing `js` to this function.
registerContentScript: (id: string, js: string) => {
id = encodeURIComponent(id);
logger.debug('Registering content script with ID', id);
hostedContentScriptJs.set(id, js);
return {
uri: `${pluginProtocolName}://plugins/content-script/${id}`,
revoke: () => {
hostedContentScriptJs.delete(id);
},
};
},
};
return handler;
};
// Creating a custom protocol allows us to isolate iframes by giving them
// different domain names from the main Joplin app.
//
// For example, an iframe with url joplin-content://note-viewer/path/to/iframe.html will run
// in a different process from a parent frame with url file://path/to/iframe.html.
//
// See note_viewer_isolation.md for why this is important.
//
// See also the protocol.handle example: https://www.electronjs.org/docs/latest/api/protocol#protocolhandlescheme-handler
//
const handleCustomProtocols = (): CustomProtocolHandlers => {
const logger = {
// Disabled for now
debug: (..._message: unknown[]) => {},
};
const appContent = handleContentProtocol(logger);
const pluginContent = handlePluginProtocol(logger);
return { appContent, pluginContent };
};
export default handleCustomProtocols;
@@ -1,11 +1,11 @@
import { protocol } from 'electron';
import { contentProtocolName } from './constants';
import { Privileges, protocol } from 'electron';
import { contentProtocolName, pluginProtocolName } from './constants';
// This must be called before Electron's onReady event.
// handleCustomProtocols should be called separately, after onReady.
const registerCustomProtocols = async () => {
const protocolPrivileges = {
const contentProtocolPrivileges: Privileges = {
supportFetchAPI: true,
// Don't trigger mixed content warnings (see https://stackoverflow.com/a/75988466)
@@ -20,8 +20,15 @@ const registerCustomProtocols = async () => {
corsEnabled: true,
codeCache: true,
};
// The plugin protocol is currently only used for serving JS and doesn't require as
// broad privileges.
const pluginProtocolPrivileges: Privileges = {
secure: true,
standard: true,
};
protocol.registerSchemesAsPrivileged([
{ scheme: contentProtocolName, privileges: protocolPrivileges },
{ scheme: contentProtocolName, privileges: contentProtocolPrivileges },
{ scheme: pluginProtocolName, privileges: pluginProtocolPrivileges },
]);
};
@@ -33,8 +33,14 @@ const useCodeMirrorPlugins = (pluginStates: PluginStates) => {
plugins.push({
pluginId,
contentScriptId,
contentScriptJs: async () => {
return await shim.fsDriver().readFile(contentScript.path);
contentScriptJs: async (context) => {
return {
sourceJs: [
context.contentScriptStartJs,
await shim.fsDriver().readFile(contentScript.path),
context.contentScriptEndJs,
].join('\n'),
};
},
loadCssAsset: (name: string) => {
// TODO: This logic is currently shared with app-desktop. Refactor
+17 -14
View File
@@ -9,7 +9,20 @@ import loadLanguages from './testing/loadLanguages';
import { expect, describe, it } from '@jest/globals';
import createEditorSettings from '../testing/createEditorSettings';
import { ContentScriptLoadOptions } from '../types';
const getMockContentScriptSource = (context: ContentScriptLoadOptions) => {
return {
sourceJs: `
${context.contentScriptStartJs}
exports.default = context => {
context.postMessage(context.pluginId);
return {};
};
${context.contentScriptEndJs}
`,
};
};
describe('createEditor', () => {
beforeAll(() => {
@@ -76,13 +89,8 @@ describe('createEditor', () => {
resolveImageSrc: src=>Promise.resolve(src),
});
const getContentScriptJs = jest.fn(async () => {
return `
exports.default = context => {
context.postMessage(context.pluginId);
return {};
};
`;
const getContentScriptJs = jest.fn(async (context) => {
return getMockContentScriptSource(context);
});
const postMessageHandler = jest.fn();
@@ -147,13 +155,8 @@ describe('createEditor', () => {
resolveImageSrc: src=>Promise.resolve(src),
});
const getContentScriptJs = jest.fn(async () => {
return `
exports.default = context => {
context.postMessage(context.pluginId);
return {};
};
`;
const getContentScriptJs = jest.fn(async (context) => {
return getMockContentScriptSource(context);
});
const postMessageHandler = jest.fn();
@@ -72,27 +72,32 @@ export default class PluginLoader {
void (async () => {
const scriptId = pluginScriptIdCounter++;
const js = await plugin.contentScriptJs();
const js = await plugin.contentScriptJs({
contentScriptStartJs: `
(async () => {
const exports = {};
const module = { exports: exports };
const require = window.__pluginLoaderRequireFunctions[${JSON.stringify(this.pluginLoaderId)}];
const joplin = {
require,
};
`,
contentScriptEndJs: `
window.__pluginLoaderScriptLoadCallbacks[${JSON.stringify(scriptId)}](module.exports);
})();
`,
});
// Stop if cancelled
if (!this.loadedContentScriptIds.includes(contentScriptToId(plugin))) {
return;
}
scriptElement.appendChild(document.createTextNode(`
(async () => {
const exports = {};
const module = { exports: exports };
const require = window.__pluginLoaderRequireFunctions[${JSON.stringify(this.pluginLoaderId)}];
const joplin = {
require,
};
${js};
window.__pluginLoaderScriptLoadCallbacks[${JSON.stringify(scriptId)}](module.exports);
})();
`));
if (js.sourceJs) {
scriptElement.appendChild(document.createTextNode(js.sourceJs));
} else {
scriptElement.src = js.uri;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
(window as any).__pluginLoaderScriptLoadCallbacks[scriptId] = onLoad;
+26 -1
View File
@@ -81,12 +81,37 @@ export enum EditorCommandType {
JumpToHash = 'jumpToHash',
}
export interface ContentScriptLoadOptions {
// The startJs/endJs options are responsible for setting up the content script
// environment and, among other things, should:
// - Set up `joplin.require`.
// - Forward `module.exports` to the editor.
// - Notify the editor when the content script has finished loading.
//
// The content script should be built similar to the following:
//
// const scriptContent = `${contentScriptStartJs}${content}${contentScriptEndJs}`;
//
contentScriptStartJs: string;
contentScriptEndJs: string;
}
type ContentScriptUriSource = {
sourceJs?: undefined;
uri: string;
};
type ContentScriptInlineSource = {
sourceJs: string;
uri?: undefined;
};
type ContentScriptJs = ContentScriptUriSource|ContentScriptInlineSource;
// Because the editor package can run in a WebView, plugin content scripts
// need to be provided as text, rather than as file paths.
export interface ContentScriptData {
pluginId: string;
contentScriptId: string;
contentScriptJs: ()=> Promise<string>;
contentScriptJs: (context: ContentScriptLoadOptions)=> Promise<ContentScriptJs>;
loadCssAsset: (name: string)=> Promise<string>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
postMessageHandler: (message: any)=> any;