mirror of
https://github.com/laurent22/joplin.git
synced 2026-10-25 02:47:28 +00:00
Desktop: Security: Strengthen Content-Security-Policy (#14316)
This commit is contained in:
1 parent
14284be1c2
commit
e3028e39fe
19 files changed
+427
-121
No files matched your search
+3
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 () => {};
|
||||
|
||||
+99
@@ -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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
+19
-12
@@ -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 },
|
||||
]);
|
||||
};
|
||||
|
||||
|
||||
+8
-2
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user