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

@@ -0,0 +1,96 @@
import { it, describe, expect } from 'vitest';
import { BridgeConnector, MethodContextBinder, connectApisWithContextBridge } from '@/presentation/electron/preload/ContextBridging/ApiContextBridge';
describe('ApiContextBridge', () => {
describe('connectApisWithContextBridge', () => {
it('connects properties as keys', () => {
// arrange
const context = new BridgeConnectorTestContext();
const { exposedItems, bridgeConnector } = mockBridgeConnector();
const expectedKeys = ['a', 'b'];
const api = {
[`${expectedKeys[0]}`]: () => {},
[`${expectedKeys[1]}`]: () => {},
};
// act
context
.withApiObject(api)
.withBridgeConnector(bridgeConnector)
.run();
// assert
expect(exposedItems).to.have.lengthOf(expectedKeys.length);
expect(exposedItems.map(([key]) => key)).to.have.members(expectedKeys);
});
it('connects values after binding their context', () => {
// arrange
const context = new BridgeConnectorTestContext();
const { exposedItems, bridgeConnector } = mockBridgeConnector();
const rawValues = ['a', 'b'];
const api = {
first: rawValues[0],
second: rawValues[1],
};
const boundValues = {
[`${rawValues[0]}`]: 'bound-a',
[`${rawValues[1]}`]: 'bound-b',
};
const expectedValues = Object.values(boundValues);
const contextBinderMock: MethodContextBinder = (property) => {
return boundValues[property as string] as never;
};
// act
context
.withApiObject(api)
.withContextBinder(contextBinderMock)
.withBridgeConnector(bridgeConnector)
.run();
// assert
expect(exposedItems).to.have.lengthOf(rawValues.length);
expect(exposedItems.map(([,value]) => value)).to.have.members(expectedValues);
});
});
});
function mockBridgeConnector() {
const exposedItems = new Array<[string, unknown]>();
const bridgeConnector: BridgeConnector = (key, api) => exposedItems.push([key, api]);
return {
exposedItems,
bridgeConnector,
};
}
class BridgeConnectorTestContext {
private bridgeConnector: BridgeConnector = () => {};
private apiObject: object = {};
private contextBinder: MethodContextBinder = (obj) => obj;
public withBridgeConnector(bridgeConnector: BridgeConnector): this {
this.bridgeConnector = bridgeConnector;
return this;
}
public withApiObject(apiObject: object): this {
this.apiObject = apiObject;
return this;
}
public withContextBinder(contextBinder: MethodContextBinder): this {
this.contextBinder = contextBinder;
return this;
}
public run() {
return connectApisWithContextBridge(
this.bridgeConnector,
this.apiObject,
this.contextBinder,
);
}
}

View File

@@ -0,0 +1,132 @@
/* eslint-disable max-classes-per-file */
import { describe, it, expect } from 'vitest';
import { bindObjectMethods } from '@/presentation/electron/preload/ContextBridging/MethodContextBinder';
describe('MethodContextBinder', () => {
describe('bindObjectMethods', () => {
it('binds methods of an object to itself', () => {
// arrange
class TestClass {
constructor(public value: number) {}
increment() {
this.value += 1;
}
}
const instance = new TestClass(0);
// act
const boundInstance = bindObjectMethods(instance);
boundInstance.increment();
// assert
expect(boundInstance.value).toBe(1);
});
it('handles objects without prototype methods gracefully', () => {
// arrange
const object = Object.create(null); // object without prototype
object.value = 0;
// eslint-disable-next-line func-names
object.increment = function () {
this.value += 1;
};
// act
const boundObject = bindObjectMethods(object);
// assert
expect(() => boundObject.increment()).not.toThrow();
});
it('recursively binds methods in nested objects', () => {
// arrange
const nestedObject = {
child: {
value: 0,
increment() {
this.value += 1;
},
},
};
// act
const boundObject = bindObjectMethods(nestedObject);
boundObject.child.increment();
// assert
expect(boundObject.child.value).toBe(1);
});
it('recursively binds methods in arrays', () => {
// arrange
const array = [
{
value: 0,
increment() {
this.value += 1;
},
},
];
// act
const boundArray = bindObjectMethods(array);
boundArray[0].increment();
// assert
expect(boundArray[0].value).toBe(1);
});
describe('returns the same object if it is neither an object nor an array', () => {
const testScenarios: ReadonlyArray<{
readonly description: string;
readonly value: unknown;
}> = [
{
description: 'given primitive',
value: 42,
},
{
description: 'null',
value: null,
},
{
description: 'undefined',
value: undefined,
},
];
testScenarios.forEach(({ description, value }) => {
it(description, () => {
// act
const boundValue = bindObjectMethods(value);
// assert
expect(boundValue).toBe(value);
});
});
});
it('skips binding inherited properties', () => {
// arrange
class ParentClass {
inheritedMethod() {}
}
class TestClass extends ParentClass {
constructor(public value: number) {
super();
}
increment() {
this.value += 1;
}
}
const instance = new TestClass(0);
const originalInheritedMethod = instance.inheritedMethod;
// act
const boundInstance = bindObjectMethods(instance);
// assert
expect(boundInstance.inheritedMethod).toBe(originalInheritedMethod);
});
});
});

View File

@@ -1,6 +1,6 @@
import { describe } from 'vitest';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { convertPlatformToOs } from '@/presentation/electron/preload/NodeOsMapper';
import { convertPlatformToOs } from '@/presentation/electron/preload/ContextBridging/NodeOsMapper';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
describe('NodeOsMapper', () => {

View File

@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest';
import { ApiFacadeFactory, provideWindowVariables } from '@/presentation/electron/preload/ContextBridging/RendererApiProvider';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { Logger } from '@/application/Common/Log/Logger';
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
import { CodeRunner } from '@/application/CodeRunner';
import { CodeRunnerStub } from '@tests/unit/shared/Stubs/CodeRunnerStub';
import { PropertyKeys } from '@/TypeHelpers';
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
describe('RendererApiProvider', () => {
describe('provideWindowVariables', () => {
interface WindowVariableTestCase {
readonly description: string;
setupContext(context: RendererApiProviderTestContext): RendererApiProviderTestContext;
readonly expectedValue: unknown;
}
const testScenarios: Record<PropertyKeys<Required<WindowVariables>>, WindowVariableTestCase> = {
isDesktop: {
description: 'returns true',
setupContext: (context) => context,
expectedValue: true,
},
codeRunner: (() => {
const codeRunner = new CodeRunnerStub();
const createFacadeMock: ApiFacadeFactory = (obj) => obj;
return {
description: 'encapsulates correctly',
setupContext: (context) => context
.withCodeRunner(codeRunner)
.withApiFacadeCreator(createFacadeMock),
expectedValue: codeRunner,
};
})(),
os: (() => {
const operatingSystem = OperatingSystem.WindowsPhone;
return {
description: 'returns expected',
setupContext: (context) => context.withOs(operatingSystem),
expectedValue: operatingSystem,
};
})(),
log: (() => {
const logger = new LoggerStub();
const createFacadeMock: ApiFacadeFactory = (obj) => obj;
return {
description: 'encapsulates correctly',
setupContext: (context) => context
.withLogger(logger)
.withApiFacadeCreator(createFacadeMock),
expectedValue: logger,
};
})(),
};
Object.entries(testScenarios).forEach((
[property, { description, setupContext, expectedValue }],
) => {
it(`${property}: ${description}`, () => {
// arrange
const testContext = setupContext(new RendererApiProviderTestContext());
// act
const variables = testContext.provideWindowVariables();
// assert
const actualValue = variables[property];
expect(actualValue).to.equal(expectedValue);
});
});
});
});
class RendererApiProviderTestContext {
private codeRunner: CodeRunner = new CodeRunnerStub();
private os: OperatingSystem = OperatingSystem.Android;
private log: Logger = new LoggerStub();
private apiFacadeCreator: ApiFacadeFactory = (obj) => obj;
public withCodeRunner(codeRunner: CodeRunner): this {
this.codeRunner = codeRunner;
return this;
}
public withOs(os: OperatingSystem): this {
this.os = os;
return this;
}
public withLogger(log: Logger): this {
this.log = log;
return this;
}
public withApiFacadeCreator(apiFacadeCreator: ApiFacadeFactory): this {
this.apiFacadeCreator = apiFacadeCreator;
return this;
}
public provideWindowVariables() {
return provideWindowVariables(
() => this.codeRunner,
() => this.log,
() => this.os,
this.apiFacadeCreator,
);
}
}

View File

@@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest';
import { createSecureFacade } from '@/presentation/electron/preload/ContextBridging/SecureFacadeCreator';
describe('SecureFacadeCreator', () => {
describe('createSecureFacade', () => {
describe('methods', () => {
it('allows access to external methods', () => {
// arrange
let value = 0;
const testObject = {
increment: () => value++,
};
const facade = createSecureFacade(testObject, ['increment']);
// act
facade.increment();
// assert
expect(value).toBe(1);
});
it('proxies external methods', () => {
// arrange
const testObject = {
method: () => {},
};
const facade = createSecureFacade(testObject, ['method']);
// act
const actualMethod = facade.method;
// assert
expect(testObject.method).not.equal(actualMethod);
expect(testObject.method).not.equal(actualMethod);
});
it('does not expose internal methods', () => {
// arrange
interface External {
publicMethod(): void;
}
interface Internal {
privateMethod(): void;
}
const testObject: External & Internal = {
publicMethod: () => {},
privateMethod: () => {},
};
const facade = createSecureFacade<External>(testObject, ['publicMethod']);
// act
facade.publicMethod();
// assert
expect((facade as unknown as Internal).privateMethod).toBeUndefined();
});
it('maintains original function context', () => {
// arrange
const testObject = {
value: 0,
increment() { this.value++; },
};
// act
const facade = createSecureFacade(testObject, ['increment', 'value']);
// assert
facade.increment();
expect(testObject.value).toBe(1);
});
});
describe('properties', () => {
it('allows access to external properties', () => {
// arrange
const testObject = { a: 1 };
// act
const facade = createSecureFacade(testObject, ['a']);
// assert
expect(facade.a).toBe(1);
});
it('does not expose internal properties', () => {
// arrange
interface External {
readonly public: string;
}
interface Internal {
readonly private: string;
}
const testObject: External & Internal = {
public: '',
private: '',
};
const facade = createSecureFacade<External>(testObject, ['public']);
// act
(() => facade.public)();
// assert
expect((facade as unknown as Internal).private).toBeUndefined();
});
});
});
});

View File

@@ -1,82 +0,0 @@
import { describe, it, expect } from 'vitest';
import { provideWindowVariables } from '@/presentation/electron/preload/WindowVariablesProvider';
import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { ISystemOperations } from '@/infrastructure/SystemOperations/ISystemOperations';
import { Logger } from '@/application/Common/Log/Logger';
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
describe('WindowVariablesProvider', () => {
describe('provideWindowVariables', () => {
it('returns expected `system`', () => {
// arrange
const expectedValue = new SystemOperationsStub();
// act
const variables = new TestContext()
.withSystem(expectedValue)
.provideWindowVariables();
// assert
expect(variables.system).to.equal(expectedValue);
});
it('returns expected `os`', () => {
// arrange
const expectedValue = OperatingSystem.WindowsPhone;
// act
const variables = new TestContext()
.withOs(expectedValue)
.provideWindowVariables();
// assert
expect(variables.os).to.equal(expectedValue);
});
it('returns expected `log`', () => {
// arrange
const expectedValue = new LoggerStub();
// act
const variables = new TestContext()
.withLogger(expectedValue)
.provideWindowVariables();
// assert
expect(variables.log).to.equal(expectedValue);
});
it('`isDesktop` is true', () => {
// arrange
const expectedValue = true;
// act
const variables = new TestContext()
.provideWindowVariables();
// assert
expect(variables.isDesktop).to.equal(expectedValue);
});
});
});
class TestContext {
private system: ISystemOperations = new SystemOperationsStub();
private os: OperatingSystem = OperatingSystem.Android;
private log: Logger = new LoggerStub();
public withSystem(system: ISystemOperations): this {
this.system = system;
return this;
}
public withOs(os: OperatingSystem): this {
this.os = os;
return this;
}
public withLogger(log: Logger): this {
this.log = log;
return this;
}
public provideWindowVariables() {
return provideWindowVariables(
() => this.system,
() => this.log,
() => this.os,
);
}
}