Improve security by isolating code execution more

This commit enhances application security against potential attacks by
isolating dependencies that access the host system (like file
operations) from the renderer process. It narrows the exposed
functionality to script execution only, adding an extra security layer.

The changes allow secure and scalable API exposure, preparing for future
functionalities such as desktop notifications for script errors (#264),
improved script execution handling (#296), and creating restore points
(#50) in a secure and repeatable way.

Changes include:

- Inject `CodeRunner` into Vue components via dependency injection.
- Move `CodeRunner` to the application layer as an abstraction for
  better domain-driven design alignment.
- Refactor `SystemOperations` and related interfaces, removing the `I`
  prefix.
- Update architecture documentation for clarity.
- Update return types in `NodeSystemOperations` to match the Node APIs.
- Improve `WindowVariablesProvider` integration tests for better error
  context.
- Centralize type checks with common functions like `isArray` and
  `isNumber`.
- Change `CodeRunner` to use `os` parameter, ensuring correct window
  variable injection.
- Streamline API exposure to the renderer process:
  - Automatically bind function contexts to prevent loss of original
    context.
  - Implement a way to create facades (wrapper/proxy objects) for
    increased security.
This commit is contained in:
undergroundwires
2023-12-18 17:30:56 +01:00
parent 940febc3e8
commit efa05f42bc
60 changed files with 939 additions and 423 deletions

View File

@@ -13,6 +13,7 @@ import {
import { PropertyKeys } from '@/TypeHelpers';
import { useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
import { useLogger } from '@/presentation/components/Shared/Hooks/UseLogger';
import { useCodeRunner } from '@/presentation/components/Shared/Hooks/UseCodeRunner';
export function provideDependencies(
context: IApplicationContext,
@@ -62,6 +63,10 @@ export function provideDependencies(
InjectionKeys.useLogger,
useLogger,
),
useCodeRunner: (di) => di.provide(
InjectionKeys.useCodeRunner,
useCodeRunner,
),
};
registerAll(Object.values(resolvers), api);
}

View File

@@ -11,8 +11,6 @@
import { defineComponent, computed } from 'vue';
import { injectKey } from '@/presentation/injectionSymbols';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { CodeRunner } from '@/infrastructure/CodeRunner';
import { IReadOnlyApplicationContext } from '@/application/Context/IApplicationContext';
import IconButton from './IconButton.vue';
export default defineComponent({
@@ -22,11 +20,19 @@ export default defineComponent({
setup() {
const { currentState, currentContext } = injectKey((keys) => keys.useCollectionState);
const { os, isDesktop } = injectKey((keys) => keys.useRuntimeEnvironment);
const { codeRunner } = injectKey((keys) => keys.useCodeRunner);
const canRun = computed<boolean>(() => getCanRunState(currentState.value.os, isDesktop, os));
async function executeCode() {
await runCode(currentContext);
if (!codeRunner) { throw new Error('missing code runner'); }
if (os === undefined) { throw new Error('unidentified host operating system'); }
await codeRunner.runCode(
currentContext.state.code.current,
currentContext.app.info.name,
currentState.value.collection.scripting.fileExtension,
os,
);
}
return {
@@ -45,13 +51,4 @@ function getCanRunState(
const isRunningOnSelectedOs = selectedOs === hostOs;
return isDesktopVersion && isRunningOnSelectedOs;
}
async function runCode(context: IReadOnlyApplicationContext) {
const runner = new CodeRunner();
await runner.runCode(
/* code: */ context.state.code.current,
/* appName: */ context.app.info.name,
/* fileExtension: */ context.state.collection.scripting.fileExtension,
);
}
</script>

View File

@@ -1,3 +1,4 @@
import { isArray } from '@/TypeHelpers';
import { TreeInputNodeData } from '../../Bindings/TreeInputNodeData';
import { TreeNode } from '../../Node/TreeNode';
import { TreeNodeManager } from '../../Node/TreeNodeManager';
@@ -5,7 +6,7 @@ import { TreeNodeManager } from '../../Node/TreeNodeManager';
export function parseTreeInput(
input: readonly TreeInputNodeData[],
): TreeNode[] {
if (!Array.isArray(input)) {
if (!isArray(input)) {
throw new Error('input data must be an array');
}
const nodes = input.map((nodeData) => createNode(nodeData));

View File

@@ -0,0 +1,9 @@
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
export function useCodeRunner(
window: WindowVariables = globalThis.window,
) {
return {
codeRunner: window.codeRunner,
};
}

View File

@@ -0,0 +1,17 @@
import { contextBridge } from 'electron';
import { bindObjectMethods } from './MethodContextBinder';
import { provideWindowVariables } from './RendererApiProvider';
export function connectApisWithContextBridge(
bridgeConnector: BridgeConnector = contextBridge.exposeInMainWorld,
apiObject: object = provideWindowVariables(),
methodContextBinder: MethodContextBinder = bindObjectMethods,
) {
Object.entries(apiObject).forEach(([key, value]) => {
bridgeConnector(key, methodContextBinder(value));
});
}
export type BridgeConnector = typeof contextBridge.exposeInMainWorld;
export type MethodContextBinder = typeof bindObjectMethods;

View File

@@ -0,0 +1,52 @@
import {
isArray, isFunction, isNullOrUndefined, isPlainObject,
} from '@/TypeHelpers';
/**
* Binds method contexts to their original object instances and recursively processes
* nested objects and arrays. This is particularly useful when exposing objects across
* different contexts in Electron, such as from the main process to the renderer process
* via the `contextBridge`.
*
* In Electron's context isolation environment, methods of objects passed through the
* `contextBridge` lose their original context (`this` binding). This function ensures that
* each method retains its binding to its original object, allowing it to work as intended
* when invoked from the renderer process.
*
* This approach decouples context isolation concerns from class implementations, enabling
* classes to operate normally without needing explicit binding or arrow functions to maintain
* the context.
*/
export function bindObjectMethods<T>(obj: T): T {
if (isNullOrUndefined(obj)) {
return obj;
}
if (isPlainObject(obj)) {
bindMethodsOfObject(obj);
Object.values(obj).forEach((value) => {
if (!isNullOrUndefined(value) && !isFunction(value)) {
bindObjectMethods(value);
}
});
} else if (isArray(obj)) {
obj.forEach((item) => bindObjectMethods(item));
}
return obj;
}
function bindMethodsOfObject<T>(obj: T): T {
const prototype = Object.getPrototypeOf(obj);
if (!prototype) {
return obj;
}
Object.getOwnPropertyNames(prototype).forEach((property) => {
if (!prototype.hasOwnProperty.call(obj, property)) {
return; // Skip properties not directly on the prototype
}
const value = obj[property];
if (isFunction(value)) {
(obj as object)[property] = value.bind(obj);
}
});
return obj;
}

View File

@@ -0,0 +1,27 @@
import { createElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { Logger } from '@/application/Common/Log/Logger';
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
import { TemporaryFileCodeRunner } from '@/infrastructure/CodeRunner/TemporaryFileCodeRunner';
import { CodeRunner } from '@/application/CodeRunner';
import { convertPlatformToOs } from './NodeOsMapper';
import { createSecureFacade } from './SecureFacadeCreator';
export function provideWindowVariables(
createCodeRunner: CodeRunnerFactory = () => new TemporaryFileCodeRunner(),
createLogger: LoggerFactory = () => createElectronLogger(),
convertToOs = convertPlatformToOs,
createApiFacade: ApiFacadeFactory = createSecureFacade,
): WindowVariables {
return {
isDesktop: true,
log: createApiFacade(createLogger(), ['info', 'debug', 'warn', 'error']),
os: convertToOs(process.platform),
codeRunner: createApiFacade(createCodeRunner(), ['runCode']),
};
}
export type LoggerFactory = () => Logger;
export type CodeRunnerFactory = () => CodeRunner;
export type ApiFacadeFactory = typeof createSecureFacade;

View File

@@ -0,0 +1,42 @@
import { isFunction } from '@/TypeHelpers';
/**
* Creates a secure proxy for the specified object, exposing only the public properties
* of its interface.
*
* This approach prevents the full exposure of the object, thereby reducing the risk
* of unintended access or misuse. For instance, creating a facade for a class rather
* than exposing the class itself ensures that private members and dependencies
* (such as file access or internal state) remain encapsulated and inaccessible.
*/
export function createSecureFacade<T>(
originalObject: T,
accessibleMembers: KeyTypeCombinations<T>,
): T {
const facade: Partial<T> = {};
accessibleMembers.forEach((key: keyof T) => {
const member = originalObject[key];
if (isFunction(member)) {
facade[key] = ((...args: unknown[]) => {
return member.apply(originalObject, args);
}) as T[keyof T];
} else {
facade[key] = member;
}
});
return facade as T;
}
type PrependTuple<H, T extends readonly unknown[]> = H extends unknown ? T extends unknown ?
((h: H, ...t: T) => void) extends ((...r: infer R) => void) ? R : never : never : never;
type RecursionDepthControl = [
never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
];
type AllKeyCombinations<T, U = T, N extends number = 15> = T extends unknown ?
PrependTuple<T, Exclude<U, T> extends infer X ? {
0: [], 1: AllKeyCombinations<X, X, RecursionDepthControl[N]>
}[[X] extends [never] ? 0 : 1] : never> :
never;
type KeyTypeCombinations<T> = AllKeyCombinations<keyof T>;

View File

@@ -1,18 +0,0 @@
import { createNodeSystemOperations } from '@/infrastructure/SystemOperations/NodeSystemOperations';
import { createElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { Logger } from '@/application/Common/Log/Logger';
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
import { convertPlatformToOs } from './NodeOsMapper';
export function provideWindowVariables(
createSystem = createNodeSystemOperations,
createLogger: () => Logger = () => createElectronLogger(),
convertToOs = convertPlatformToOs,
): WindowVariables {
return {
system: createSystem(),
isDesktop: true,
log: createLogger(),
os: convertToOs(process.platform),
};
}

View File

@@ -1,9 +1,8 @@
// This file is used to securely expose Electron APIs to the application.
import { contextBridge } from 'electron';
import { validateRuntimeSanity } from '@/infrastructure/RuntimeSanity/SanityChecks';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { provideWindowVariables } from './WindowVariablesProvider';
import { connectApisWithContextBridge } from './ContextBridging/ApiContextBridge';
validateRuntimeSanity({
// Validate metadata as a preventive measure for fail-fast,
@@ -14,10 +13,7 @@ validateRuntimeSanity({
validateWindowVariables: false,
});
const windowVariables = provideWindowVariables();
Object.entries(windowVariables).forEach(([key, value]) => {
contextBridge.exposeInMainWorld(key, value);
});
connectApisWithContextBridge();
// Do not remove [PRELOAD_INIT]; it's a marker used in tests.
ElectronLogger.info('[PRELOAD_INIT] Preload script successfully initialized and executed.');

View File

@@ -7,6 +7,7 @@ import type { useCurrentCode } from '@/presentation/components/Shared/Hooks/UseC
import type { useAutoUnsubscribedEvents } from '@/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents';
import type { useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
import type { useLogger } from '@/presentation/components/Shared/Hooks/UseLogger';
import type { useCodeRunner } from './components/Shared/Hooks/UseCodeRunner';
export const InjectionKeys = {
useCollectionState: defineTransientKey<ReturnType<typeof useCollectionState>>('useCollectionState'),
@@ -17,6 +18,7 @@ export const InjectionKeys = {
useCurrentCode: defineTransientKey<ReturnType<typeof useCurrentCode>>('useCurrentCode'),
useUserSelectionState: defineTransientKey<ReturnType<typeof useUserSelectionState>>('useUserSelectionState'),
useLogger: defineTransientKey<ReturnType<typeof useLogger>>('useLogger'),
useCodeRunner: defineTransientKey<ReturnType<typeof useCodeRunner>>('useCodeRunner'),
};
export interface InjectionKeyWithLifetime<T> {