Compare commits

..
Author SHA1 Message Date
Laurent Cozic 318cfb5729 update 2026-11-08 18:28:18 +01:00
22 changed files with 368 additions and 59 deletions

No files matched your search

+4
View File
@@ -686,6 +686,7 @@ packages/app-desktop/services/plugins/hooks/useThemeCss.test.js
packages/app-desktop/services/plugins/hooks/useThemeCss.js
packages/app-desktop/services/plugins/hooks/useViewIsReady.js
packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.js
packages/app-desktop/services/plugins/startBlockedPluginsCheck.js
packages/app-desktop/services/plugins/types.js
packages/app-desktop/services/restart.js
packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.js
@@ -1878,8 +1879,11 @@ packages/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo.js
packages/lib/services/plugins/loadPlugins.test.js
packages/lib/services/plugins/loadPlugins.js
packages/lib/services/plugins/reducer.js
packages/lib/services/plugins/refreshBlockedPluginVersions.js
packages/lib/services/plugins/testing/MockPlatformImplementation.js
packages/lib/services/plugins/testing/MockPluginRunner.js
packages/lib/services/plugins/utils/blockedVersions.test.js
packages/lib/services/plugins/utils/blockedVersions.js
packages/lib/services/plugins/utils/createViewHandle.js
packages/lib/services/plugins/utils/executeSandboxCall.js
packages/lib/services/plugins/utils/getActivePluginEditorView.js
+4
View File
@@ -712,6 +712,7 @@ packages/app-desktop/services/plugins/hooks/useThemeCss.test.js
packages/app-desktop/services/plugins/hooks/useThemeCss.js
packages/app-desktop/services/plugins/hooks/useViewIsReady.js
packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.js
packages/app-desktop/services/plugins/startBlockedPluginsCheck.js
packages/app-desktop/services/plugins/types.js
packages/app-desktop/services/restart.js
packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.js
@@ -1904,8 +1905,11 @@ packages/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo.js
packages/lib/services/plugins/loadPlugins.test.js
packages/lib/services/plugins/loadPlugins.js
packages/lib/services/plugins/reducer.js
packages/lib/services/plugins/refreshBlockedPluginVersions.js
packages/lib/services/plugins/testing/MockPlatformImplementation.js
packages/lib/services/plugins/testing/MockPluginRunner.js
packages/lib/services/plugins/utils/blockedVersions.test.js
packages/lib/services/plugins/utils/blockedVersions.js
packages/lib/services/plugins/utils/createViewHandle.js
packages/lib/services/plugins/utils/executeSandboxCall.js
packages/lib/services/plugins/utils/getActivePluginEditorView.js
@@ -1,14 +1,26 @@
import { AppType } from '@joplin/lib/models/Setting';
import RepositoryApi, { AppInfo, InstallMode } from '@joplin/lib/services/plugins/RepositoryApi';
import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
import refreshBlockedPluginVersions from '@joplin/lib/services/plugins/refreshBlockedPluginVersions';
import { setupDatabaseAndSynchronizer, switchClient, supportDir, createTempDir } from '@joplin/lib/testing/test-utils';
import { remove } from 'fs-extra';
import { copy, mkdirp, readJson, remove, writeFile, writeJson } from 'fs-extra';
let tempDirs: string[] = [];
async function newRepoApi(appInfo: AppInfo = { type: AppType.Desktop, version: '3.0.0' }): Promise<RepositoryApi> {
async function newRepoApi(appInfo: AppInfo = { type: AppType.Desktop, version: '3.0.0' }, blockedVersions: Record<string, string[]> = null): Promise<RepositoryApi> {
const tempDir = await createTempDir();
tempDirs.push(tempDir);
const repo = new RepositoryApi(`${supportDir}/pluginRepo`, tempDir, appInfo, InstallMode.Default);
let repoDir = `${supportDir}/pluginRepo`;
if (blockedVersions) {
repoDir = `${tempDir}/pluginRepo`;
await copy(`${supportDir}/pluginRepo`, repoDir);
const manifests = await readJson(`${repoDir}/manifests.json`);
for (const [id, versions] of Object.entries(blockedVersions)) manifests[id]._blocked_versions = versions;
await writeJson(`${repoDir}/manifests.json`, manifests);
}
const repo = new RepositoryApi(repoDir, tempDir, appInfo, InstallMode.Default);
await repo.initialize();
return repo;
}
@@ -67,4 +79,33 @@ describe('services_plugins_RepositoryApi', () => {
expect(await api.pluginCanBeUpdated(id, installedVersion)).toBe(shouldBeUpdatable);
}));
it.each([
['1.0.2'],
['>=1.0.1'],
['*'],
])('should not update to or download a blocked version (%s)', (async (blockedVersion) => {
const id = 'org.joplinapp.plugins.ToggleSidebars';
const api = await newRepoApi(undefined, { [id]: [blockedVersion] });
expect(await api.pluginCanBeUpdated(id, '1.0.0')).toBe(false);
await expect(api.downloadPlugin(id)).rejects.toThrow('has been blocked');
}));
it('should cache the blocked versions only when plugins are installed', (async () => {
const blockedVersions = { 'org.joplinapp.plugins.ToggleSidebars': ['1.0.1', '1.0.2'] };
const api = await newRepoApi(undefined, blockedVersions);
await refreshBlockedPluginVersions(api);
expect(Setting.value('plugins.blockedVersions')).toEqual({});
const pluginPath = `${Setting.value('pluginDir')}/test.jpl`;
await mkdirp(Setting.value('pluginDir'));
await writeFile(pluginPath, '');
try {
await refreshBlockedPluginVersions(api);
expect(Setting.value('plugins.blockedVersions')).toEqual(blockedVersions);
} finally {
await remove(pluginPath);
}
}));
});
+3
View File
@@ -18,6 +18,7 @@ import { _, setLocale } from '@joplin/lib/locale';
import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerService';
import SpellCheckerServiceDriverNative from './services/spellChecker/SpellCheckerServiceDriverNative';
import bridge from './services/bridge';
import startBlockedPluginsCheck from './services/plugins/startBlockedPluginsCheck';
import menuCommandNames from './gui/menuCommandNames';
import ResourceService from '@joplin/lib/services/ResourceService';
import ExternalEditWatcher from '@joplin/lib/services/ExternalEditWatcher';
@@ -385,6 +386,8 @@ class Application extends BaseApplication {
ipcRenderer.send('startup-plugins-loaded');
void afterDefaultPluginsLoaded(service.plugins, getDefaultPluginsInfo(), pluginSettings);
startBlockedPluginsCheck(this.store());
}
}, 500);
}
@@ -0,0 +1,60 @@
import Setting, { AppType } from '@joplin/lib/models/Setting';
import RepositoryApi, { InstallMode } from '@joplin/lib/services/plugins/RepositoryApi';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import refreshBlockedPluginVersions from '@joplin/lib/services/plugins/refreshBlockedPluginVersions';
import { State, stateUtils } from '@joplin/lib/reducer';
import shim from '@joplin/lib/shim';
import time from '@joplin/lib/time';
import { _ } from '@joplin/lib/locale';
import Logger from '@joplin/utils/Logger';
import { Store } from 'redux';
import bridge from '../bridge';
import restart from '../restart';
const logger = Logger.create('startBlockedPluginsCheck');
const waitForNotesSaved = async (store: Store<State>) => {
const startTime = Date.now();
while (stateUtils.hasNotesBeingSaved(store.getState()) && Date.now() - startTime < 5000) {
await time.msleep(100);
}
};
// A blocked plugin can't be fully unloaded on desktop since its content scripts run in the main
// window, so we restart straight away rather than wait for the user, who may not be at their computer.
const restartForBlockedPlugins = async (store: Store<State>, pluginIds: string[]) => {
logger.warn('Restarting because these running plugins have been blocked:', pluginIds);
const service = PluginService.instance();
Setting.setValue('plugins.blockedRestartNotice', pluginIds.map(id => service.pluginById(id).manifest.name));
await waitForNotesSaved(store);
await restart();
};
const showBlockedRestartNotice = () => {
const pluginNames: string[] = Setting.value('plugins.blockedRestartNotice');
if (!pluginNames.length) return;
Setting.setValue('plugins.blockedRestartNotice', []);
bridge().showInfoMessageBox(_('Joplin was restarted because the following plugins have been disabled by the Joplin team for security reasons: %s\n\nWe recommend uninstalling them.', pluginNames.join(', ')));
};
const startBlockedPluginsCheck = (store: Store<State>) => {
showBlockedRestartNotice();
const repoApi = RepositoryApi.ofDefaultJoplinRepo(
Setting.value('tempDir'), { type: AppType.Desktop, version: PluginService.instance().appVersion }, InstallMode.Default,
);
const check = async () => {
try {
const blockedRunningPluginIds = await refreshBlockedPluginVersions(repoApi);
if (blockedRunningPluginIds.length) await restartForBlockedPlugins(store, blockedRunningPluginIds);
} catch (error) {
logger.warn('Could not refresh blocked plugin versions:', error);
}
};
void check();
shim.setInterval(() => { void check(); }, 60 * 60 * 1000);
};
export default startBlockedPluginsCheck;
@@ -9,6 +9,7 @@ import { connect, useStore } from 'react-redux';
import Logger from '@joplin/utils/Logger';
import PluginService, { PluginSettings, SerializedPluginSettings } from '@joplin/lib/services/plugins/PluginService';
import { PluginHtmlContents, PluginStates } from '@joplin/lib/services/plugins/reducer';
import { BlockedPluginVersions } from '@joplin/lib/services/plugins/utils/blockedVersions';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import PluginDialogManager from './dialogs/PluginDialogManager';
import { AppState } from '../../utils/types';
@@ -33,6 +34,7 @@ const usePlugins = (
pluginSettings: PluginSettings,
pluginSupportEnabled: boolean,
devPluginPath: string,
blockedPluginVersions: BlockedPluginVersions,
) => {
const store = useStore<AppState>();
const lastPluginRunner = usePrevious(pluginRunner);
@@ -71,7 +73,7 @@ const usePlugins = (
if (!event.cancelled) {
reloadAllRef.current = false;
}
}, [pluginRunner, store, webviewLoaded, pluginSettings, reloadCounter]);
}, [pluginRunner, store, webviewLoaded, pluginSettings, reloadCounter, blockedPluginVersions]);
};
const useUnloadPluginsOnGlobalDisable = (
@@ -95,6 +97,7 @@ interface Props {
pluginSupportEnabled: boolean;
pluginStates: PluginStates;
devPluginPath: string;
blockedPluginVersions: BlockedPluginVersions;
pluginHtmlContents: PluginHtmlContents;
themeId: number;
}
@@ -125,7 +128,7 @@ const PluginRunnerWebViewComponent: React.FC<Props> = props => {
}, [webviewReloadCounter]);
const pluginSettings = usePluginSettings(props.serializedPluginSettings);
usePlugins(pluginRunner, webviewLoaded, pluginSettings, props.pluginSupportEnabled, props.devPluginPath);
usePlugins(pluginRunner, webviewLoaded, pluginSettings, props.pluginSupportEnabled, props.devPluginPath, props.blockedPluginVersions);
useUnloadPluginsOnGlobalDisable(props.pluginStates, props.pluginSupportEnabled);
const onLoadStart = useCallback(() => {
@@ -213,6 +216,7 @@ export default connect((state: AppState) => {
serializedPluginSettings: state.settings['plugins.states'],
pluginSupportEnabled: state.settings['plugins.pluginSupportEnabled'],
devPluginPath: state.settings['plugins.devPluginPaths'],
blockedPluginVersions: state.settings['plugins.blockedVersions'],
pluginStates: state.pluginService.plugins,
pluginHtmlContents: state.pluginService.pluginHtmlContents,
themeId: state.settings.theme,
@@ -8,6 +8,8 @@ import ResourceService from '@joplin/lib/services/ResourceService';
import KvStore from '@joplin/lib/services/KvStore';
import Setting, { AppType, Env } from '@joplin/lib/models/Setting';
import PoorManIntervals from '@joplin/lib/PoorManIntervals';
import RepositoryApi, { InstallMode } from '@joplin/lib/services/plugins/RepositoryApi';
import refreshBlockedPluginVersions from '@joplin/lib/services/plugins/refreshBlockedPluginVersions';
import { parseNotesParent } from '@joplin/lib/reducer';
import uuid from '@joplin/lib/uuid';
import { loadKeychainServiceAndSettings } from '@joplin/lib/services/SettingUtils';
@@ -515,6 +517,21 @@ const buildStartupTasks = (
const updatedSettings = pluginService.clearUpdateState(pluginSettings);
Setting.setValue('plugins.states', updatedSettings);
// Running plugins that are now blocked are unloaded by PluginRunnerWebView when the setting changes
const repoApi = RepositoryApi.ofDefaultJoplinRepo(
Setting.value('tempDir'), { type: AppType.Mobile, version: platformImplementation.versionInfo.version }, InstallMode.Default,
);
const checkBlockedPlugins = async () => {
if (!Setting.value('plugins.pluginSupportEnabled')) return;
try {
await refreshBlockedPluginVersions(repoApi);
} catch (error) {
logger.warn('Could not refresh blocked plugin versions:', error);
}
};
void checkBlockedPlugins();
PoorManIntervals.setInterval(() => { void checkBlockedPlugins(); }, 4 * 60 * 60 * 1000);
});
addTask('buildStartupTasks/run startup tests', async () => {
// ----------------------------------------------------------------------------
@@ -7,6 +7,7 @@ import type SettingType from '../Setting';
import { AppType, SettingItemSubType, SettingItemType, SettingStorage, SyncStartupOperation, SettingItem, SettingButtonStyle } from './types';
import { defaultListColumns } from '../../services/plugins/api/noteListType';
import type { PluginSettings } from '../../services/plugins/PluginService';
import type { BlockedPluginVersions } from '../../services/plugins/utils/blockedVersions';
import type { PublicPrivateKeyPair } from '../../services/e2ee/ppk/ppk';
import type { NoteLockKeyConflict } from '../../services/synchronizer/syncInfoUtils';
import { EmptyObject } from '@joplin/utils/types';
@@ -1447,6 +1448,20 @@ const builtInMetadata = (Setting: typeof SettingType) => {
autoSave: true,
},
'plugins.blockedVersions': {
value: {} as BlockedPluginVersions,
type: SettingItemType.Object,
public: false,
appTypes: [AppType.Desktop, AppType.Mobile],
},
'plugins.blockedRestartNotice': {
value: [] as string[],
type: SettingItemType.Array,
public: false,
appTypes: [AppType.Desktop],
},
'plugins.enableWebviewDebugging': {
value: false,
type: SettingItemType.Bool,
+12 -2
View File
@@ -10,6 +10,7 @@ import Logger from '@joplin/utils/Logger';
import RepositoryApi from './RepositoryApi';
import { produce } from 'immer';
import { PluginManifest } from './utils/types';
import { BlockedPluginVersions, isRepoManifestBlocked, isVersionBlocked } from './utils/blockedVersions';
import isCompatible from './utils/isCompatible';
import { AppType } from './api/types';
import minVersionForPlatform from './utils/isCompatible/minVersionForPlatform';
@@ -511,7 +512,9 @@ export default class PluginService extends BaseService {
// Load only the manifest first to check if the plugin is
// enabled before doing the expensive full load.
let plugin = await this.loadPluginFromPath(pluginPath, true);
const enabled = this.pluginEnabled(settings, plugin.id);
const blocked = this.isBlocked(plugin.manifest);
if (blocked) logger.warn(`Plugin version has been blocked: ${plugin.id}@${plugin.manifest.version}`);
const enabled = this.pluginEnabled(settings, plugin.id) && !blocked;
if (enabled) {
logger.info(`Loading full plugin: ${plugin.id}`);
plugin = await this.loadPluginFromPath(pluginPath, false);
@@ -583,8 +586,13 @@ export default class PluginService extends BaseService {
return shim.mobilePlatform() ? AppType.Mobile : AppType.Desktop;
}
public isBlocked(manifest: PluginManifest) {
const blockedVersions: BlockedPluginVersions = Setting.value('plugins.blockedVersions');
return isRepoManifestBlocked(manifest) || isVersionBlocked(blockedVersions[manifest.id], manifest.version);
}
public isCompatible(manifest: PluginManifest): boolean {
return isCompatible(this.appVersion_, this.appType_, manifest);
return isCompatible(this.appVersion_, this.appType_, manifest) && !this.isBlocked(manifest);
}
private validateManifest(manifest: unknown): void {
@@ -600,6 +608,8 @@ export default class PluginService extends BaseService {
return _('Invalid plugin manifest: %s', message);
}
if (this.isBlocked(manifest)) return _('This version of the plugin has been disabled by the Joplin team for security reasons.');
if (this.isCompatible(manifest)) return null;
const minVersion = minVersionForPlatform(this.appType_, manifest);
+11 -4
View File
@@ -4,6 +4,7 @@ import { PluginManifest } from './utils/types';
const md5 = require('md5');
import { compareVersions } from 'compare-versions';
import isCompatible from './utils/isCompatible';
import { isRepoManifestBlocked } from './utils/blockedVersions';
import { AppType } from '../../models/Setting';
const logger = Logger.create('RepositoryApi');
@@ -92,6 +93,14 @@ export default class RepositoryApi {
}
public async initialize() {
await this.initializeManifestsOnly();
await this.loadRelease();
this.lastInitializedTime_ = Date.now();
}
// Skips the GitHub release API call, which is rate-limited and only needed to download plugins
public async initializeManifestsOnly() {
// https://github.com/joplin/plugins
// https://api.github.com/repos/joplin/plugins/releases
this.githubApiUrl_ = this.baseUrl_.replace(/^(https:\/\/)(github\.com\/)(.*)$/, '$1api.$2repos/$3');
@@ -102,9 +111,6 @@ export default class RepositoryApi {
this.isUsingDefaultContentUrl_ = this.contentBaseUrl_ === defaultContentBaseUrl;
await this.loadManifests();
await this.loadRelease();
this.lastInitializedTime_ = Date.now();
}
public async reinitialize() {
@@ -245,6 +251,7 @@ export default class RepositoryApi {
const manifest = manifests.find(m => m.id === pluginId);
if (!manifest) throw new Error(`No manifest for plugin ID "${pluginId}"`);
if (this.isBlockedByInstallMode(manifest)) throw new Error(`Plugin is blocked by intstallation policy. ID "${pluginId}"`);
if (isRepoManifestBlocked(manifest)) throw new Error(`This version of the plugin has been blocked: ${pluginId}@${manifest.version}`);
const fileUrl = this.assetFileUrl(manifest.id); // this.repoFileUrl(`plugins/${manifest.id}/plugin.jpl`);
const hash = md5(Date.now() + Math.random());
@@ -284,7 +291,7 @@ export default class RepositoryApi {
if (!manifest) return false;
const supportsCurrentAppVersion = compareVersions(installedVersion, manifest.version) < 0 && isCompatible(this.appVersion_, this.appType_, manifest);
return supportsCurrentAppVersion && !this.isBlockedByInstallMode(manifest);
return supportsCurrentAppVersion && !this.isBlockedByInstallMode(manifest) && !isRepoManifestBlocked(manifest);
}
}
@@ -254,4 +254,34 @@ describe('loadPlugins', () => {
// The bad plugin should not be running
expect(failingPluginRunner.runningPluginIds).not.toContain(badPluginId);
});
test('should stop and not run blocked plugin versions', async () => {
const blockedId = 'joplin.test.plugin.blocked';
const otherId = 'joplin.test.plugin.other';
await createTestPlugin({ ...defaultManifestProperties, id: blockedId, name: 'Blocked' });
await createTestPlugin({ ...defaultManifestProperties, id: otherId, name: 'Other' });
const pluginRunner = new MockPluginRunner();
const loadPluginsOptions: LoadPluginsProps = {
pluginRunner,
pluginSettings: Setting.value('plugins.states'),
platformImplementation,
store: createMockReduxStore(),
reloadAll: false,
cancelEvent: { cancelled: false },
};
await loadPlugins(loadPluginsOptions);
await pluginRunner.waitForAllToBeRunning([blockedId, otherId]);
Setting.setValue('plugins.blockedVersions', { [blockedId]: [defaultManifestProperties.version] });
await withWarningSilenced(/Plugin version has been blocked/, async () => {
await loadPlugins(loadPluginsOptions);
});
expect(pluginRunner.runningPluginIds).toEqual([otherId]);
const blockedManifest = PluginService.instance().pluginById(blockedId).manifest;
expect(PluginService.instance().isCompatible(blockedManifest)).toBe(false);
expect(PluginService.instance().describeIncompatibility(blockedManifest)).toContain('security reasons');
});
});
@@ -0,0 +1,27 @@
import Setting from '../../models/Setting';
import shim from '../../shim';
import PluginService from './PluginService';
import RepositoryApi from './RepositoryApi';
import { blockedVersionsFromManifests } from './utils/blockedVersions';
const hasUserInstalledPlugins = async () => {
const pluginDir = Setting.value('pluginDir');
if (!(await shim.fsDriver().exists(pluginDir))) return false;
return (await shim.fsDriver().readDirStats(pluginDir)).length > 0;
};
// Returns the IDs of the running plugins that are now blocked
const refreshBlockedPluginVersions = async (repoApi: RepositoryApi) => {
if (!(await hasUserInstalledPlugins())) return [];
await repoApi.initializeManifestsOnly();
Setting.setValue('plugins.blockedVersions', blockedVersionsFromManifests(await repoApi.manifests()));
const service = PluginService.instance();
return service.pluginIds.filter(id => {
const plugin = service.pluginById(id);
return plugin.running && service.isBlocked(plugin.manifest);
});
};
export default refreshBlockedPluginVersions;
@@ -0,0 +1,35 @@
import { isValidBlockedVersionEntry, isVersionBlocked } from './blockedVersions';
describe('blockedVersions', () => {
test.each([
[['1.2.3'], '1.2.3', true],
[['1.2.3'], '1.2.4', false],
[['>=1.2.3'], '1.2.2', false],
[['>=1.2.3'], '1.2.4', true],
[['>=1.2.3'], '2.0.0', true],
[['1.2.3', '1.2.4'], '1.2.5', false],
[['*'], '0.0.1', true],
[['not a version'], '1.2.3', true],
[[], '1.2.3', false],
[undefined, '1.2.3', false],
])('should tell if a version is blocked (%j, %s)', (blockedVersions, version, expected) => {
expect(isVersionBlocked(blockedVersions, version)).toBe(expected);
});
test.each([
['*', true],
['1.2.3', true],
['>=1.2.3', true],
['>=1.2.3 <1.2.5', true],
['1.2.3 - 1.2.4', true],
['1.2', false],
['v1.2.3', false],
['>=x', false],
['', false],
[123, false],
])('should validate blocked version entries (%j)', (entry, expected) => {
expect(isValidBlockedVersionEntry(entry)).toBe(expected);
});
});
@@ -0,0 +1,48 @@
import { satisfies, validateStrict } from 'compare-versions';
import { PluginManifest } from './types';
// Maps a plugin ID to its blocked versions. Each entry is "*", an exact version, or a semver range
// such as ">=1.2.3", so that versions published by an attacker after the block are also covered.
export type BlockedPluginVersions = Record<string, string[]>;
const entryMatches = (entry: string, version: string) => {
if (entry === '*') return true;
try {
return satisfies(version, entry);
} catch (error) {
// Fail closed: a malformed entry still blocks the plugin
return true;
}
};
export const isVersionBlocked = (blockedVersions: string[] | undefined, version: string) => {
if (!Array.isArray(blockedVersions)) return false;
return blockedVersions.some(entry => entryMatches(entry, version));
};
export const isValidBlockedVersionEntry = (entry: unknown) => {
if (typeof entry !== 'string') return false;
if (entry === '*' || validateStrict(entry)) return true;
// Partial versions such as "1.2" are accepted by satisfies() but silently match nothing
if (!/[<>=^~|]|\s-\s/.test(entry)) return false;
try {
satisfies('0.0.0', entry);
return true;
} catch (error) {
return false;
}
};
export const isRepoManifestBlocked = (manifest: PluginManifest) => {
return isVersionBlocked(manifest._blocked_versions, manifest.version);
};
export const blockedVersionsFromManifests = (manifests: PluginManifest[]) => {
const output: BlockedPluginVersions = {};
for (const manifest of manifests) {
if (Array.isArray(manifest._blocked_versions) && manifest._blocked_versions.length) {
output[manifest.id] = manifest._blocked_versions.filter(v => typeof v === 'string');
}
}
return output;
};
@@ -40,4 +40,5 @@ export interface PluginManifest {
_npm_package_name?: string;
_obsolete?: boolean;
_recommended?: boolean;
_blocked_versions?: string[];
}
@@ -1,4 +1,4 @@
import { applyManifestOverrides, getObsoleteManifests, getSupersededPackages, ManifestOverrides } from './overrideUtils';
import { applyManifestOverrides, getObsoleteManifests, getSupersededPackages, ManifestOverrides, validateManifestOverrides } from './overrideUtils';
describe('overrideUtils', () => {
@@ -104,4 +104,18 @@ describe('overrideUtils', () => {
const supersededPackages = getSupersededPackages(manifestOverrides);
expect(supersededPackages).toMatchObject(['joplin-plugin-benji-favorites']);
});
test.each([
[['>=1.2.3', '1.0.0'], true],
[['1.2'], false],
[[] as string[], false],
['1.2.3', false],
])('should validate blocked versions (%j)', (blockedVersions, isValid) => {
const overrides = { 'org.joplinapp.plugins.ToggleSidebars': { _blocked_versions: blockedVersions } } as ManifestOverrides;
if (isValid) {
expect(() => validateManifestOverrides(overrides)).not.toThrow();
} else {
expect(() => validateManifestOverrides(overrides)).toThrow('Invalid _blocked_versions');
}
});
});
+15 -1
View File
@@ -1,11 +1,13 @@
import * as path from 'path';
import { readJsonFile } from './utils';
import { PluginManifest } from '@joplin/lib/services/plugins/utils/types';
import { isValidBlockedVersionEntry } from '@joplin/lib/services/plugins/utils/blockedVersions';
export interface ManifestOverride {
_obsolete?: boolean;
_recommended?: boolean;
_superseded_package?: string;
_blocked_versions?: string[];
}
export type ManifestOverrides = Record<string, ManifestOverride>;
@@ -49,8 +51,20 @@ function pluginManifestOverridesPath(repoDir: string): string {
return path.resolve(repoDir, 'manifestOverrides.json');
}
export const validateManifestOverrides = (overrides: ManifestOverrides) => {
for (const [pluginId, override] of Object.entries(overrides)) {
const blockedVersions = override._blocked_versions;
if (blockedVersions === undefined) continue;
if (!Array.isArray(blockedVersions) || !blockedVersions.length || !blockedVersions.every(isValidBlockedVersionEntry)) {
throw new Error(`Invalid _blocked_versions for plugin ${pluginId}: ${JSON.stringify(blockedVersions)}. It must be a non-empty array of "*", exact versions or semver ranges.`);
}
}
};
export async function readManifestOverrides(repoDir: string): Promise<ManifestOverrides> {
return readJsonFile(pluginManifestOverridesPath(repoDir), {});
const overrides: ManifestOverrides = await readJsonFile(pluginManifestOverridesPath(repoDir), {});
validateManifestOverrides(overrides);
return overrides;
}
export const getSupersededPackages = (manifestOverrides: ManifestOverrides): string[] => {
@@ -105,6 +105,19 @@ describe('validateUntrustedManifest', () => {
).toThrow('mark itself as recommended');
});
test('should not allow plugin authors to set their own blocked versions', () => {
const newManifest = {
id: 'joplin-plugin.this.is.another.test',
_blocked_versions: [] as string[],
_npm_package_name: 'joplin-plugin-another-test',
version: '1.2.3',
};
expect(
() => validateUntrustedManifest(newManifest as unknown as PluginManifest, originalManifests, { source: 'npm' }),
).toThrow('cannot set its own blocked versions');
});
test('should not allow changing the NPM package for an existing plugin', () => {
const newManifest = {
id: 'joplin-plugin.this.is.a.test',
@@ -26,6 +26,10 @@ const validateUntrustedManifest = (
throw new Error(`Plugin ${manifest.id} cannot mark itself as recommended.`);
}
if (typeof manifest._blocked_versions !== 'undefined') {
throw new Error(`Plugin ${manifest.id} cannot set its own blocked versions.`);
}
if (options.source === 'repository') {
if (!manifest.repository_url?.trim()) {
throw new Error(`Plugin ${manifest.id} must specify repository_url when published from a repository.`);
@@ -632,38 +632,6 @@ describe('ItemModel', () => {
expect((await models().userItem().byUserId(user1.id)).length).toBe(5);
});
test('should process orphaned items mixed with valid items across batches', async () => {
const { user: user1 } = await createUserAndSession(1);
const { user: user2 } = await createUserAndSession(2);
await createItemTree3(user1.id, '', '', [
{ id: '000000000000000000000000000000F1' },
]);
await createItemTree3(user2.id, '', '', [
{ id: '000000000000000000000000000000F2' },
{ id: '000000000000000000000000000000F3' },
{ id: '000000000000000000000000000000F4' },
{ id: '000000000000000000000000000000F5' },
]);
const f4 = await models().item().loadByJopId(user2.id, '000000000000000000000000000000F4');
await db()('user_items').where('item_id', '=', f4.id).delete();
await db()('users').where('id', '=', user1.id).delete();
await db()('user_items').where('user_id', '=', user1.id).delete();
await models().item().processOrphanedItems({ batchSize: 2 });
const items = await models().item().all();
expect(items.map(i => i.name).sort()).toEqual([
'000000000000000000000000000000F2.md',
'000000000000000000000000000000F3.md',
'000000000000000000000000000000F4.md',
'000000000000000000000000000000F5.md',
]);
expect((await models().userItem().byUserId(user2.id)).length).toBe(4);
});
test('should return multiple item contents', async () => {
const { user: user1 } = await createUserAndSession(1);
+3 -11
View File
@@ -1102,10 +1102,6 @@ export default class ItemModel extends BaseModel<Item> {
// then look up which of those ids exist in user_items. The orphan
// filter happens in TS. Per-query cost is bounded by the window size,
// not by the table size, so no statement can blow past the timeout.
//
// The window query only selects `id` so that it is an index-only scan.
// IDs are random, so any other column costs one heap page read per row,
// and walking the whole heap evicts the buffer cache used by the app.
const batchSize = options.batchSize ?? 10000;
let batchNum = 0;
let totalProcessed = 0;
@@ -1118,7 +1114,7 @@ export default class ItemModel extends BaseModel<Item> {
const batchStartTime = Date.now();
const windowItems: Item[] = await this.db(this.tableName)
.select(['items.id'])
.select(['items.id', 'items.name', 'items.owner_id'])
.where('items.id', '>', lastId)
.orderBy('items.id')
.limit(batchSize);
@@ -1137,18 +1133,14 @@ export default class ItemModel extends BaseModel<Item> {
.select('item_id')
.whereIn('item_id', windowItemIds);
const userItemIds = new Set(existingUserItems.map(u => u.item_id));
const orphanedItemIds = windowItemIds.filter(id => !userItemIds.has(id));
const orphanedItems = windowItems.filter(i => !userItemIds.has(i.id));
if (!orphanedItemIds.length) {
if (!orphanedItems.length) {
const batchDuration = Date.now() - batchStartTime;
modelLogger.info(`processOrphanedItems: Batch ${batchNum} - No orphans in window of ${windowItems.length} items (${batchDuration}ms)`);
continue;
}
const orphanedItems: Item[] = await this.db(this.tableName)
.select(['id', 'name', 'owner_id'])
.whereIn('id', orphanedItemIds);
modelLogger.info(`processOrphanedItems: Batch ${batchNum} - Found ${orphanedItems.length} orphaned items`);
for (const o of orphanedItems) {
modelLogger.info(`OrphanTrace: found orphan item=${o.id} owner=${o.owner_id} name=${o.name}`);
@@ -63,12 +63,10 @@ export default async function(env: Env, models: Models, config: Config, services
run: (models: Models) => models.user().handleOversizedAccounts(),
},
// Walks the whole items table, so it runs once a day rather than
// hourly. Orphans are rare and clients work around them meanwhile.
{
id: TaskId.ProcessOrphanedItems,
description: taskIdToLabel(TaskId.ProcessOrphanedItems),
schedule: '15 4 * * *',
schedule: '15 * * * *',
run: (models: Models) => models.item().processOrphanedItems(),
},