Improve desktop security by isolating Electron

Enable `contextIsolation` in Electron to securely expose a limited set
of Node.js APIs to the renderer process. It:

1. Isolates renderer and main process contexts. It ensures that the
   powerful main process functions aren't directly accessible from
   renderer process(es), adding a security boundary.
2. Mitigates remote exploitation risks. By isolating contexts, potential
   malicious code injections in the renderer can't directly reach and
   compromise the main process.
3. Reduces attack surface.
4. Protect against prototype pollution: It prevents tampering of
   JavaScript object prototypes in one context from affecting another
   context, improving app reliability and security.

Supporting changes include:

- Extract environment and system operations classes to the infrastructure
  layer. This removes node dependencies from core domain and application
  code.
- Introduce `ISystemOperations` to encapsulate OS interactions. Use it
  from `CodeRunner` to isolate node API usage.
- Add a preloader script to inject validated environment variables into
  renderer context. This keeps Electron integration details
  encapsulated.
- Add new sanity check to fail fast on issues with preloader injected
  variables.
- Improve test coverage of runtime sanity checks and environment
  components. Move validation logic into separate classes for Single
  Responsibility.
- Improve absent value test case generation.
This commit is contained in:
undergroundwires
2023-08-25 14:31:30 +02:00
parent 62f8bfac2f
commit e9e0001ef8
83 changed files with 1846 additions and 769 deletions

View File

@@ -0,0 +1,8 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
import { IBrowserOsDetector } from '@/infrastructure/Environment/BrowserOs/IBrowserOsDetector';
export class BrowserOsDetectorStub implements IBrowserOsDetector {
public detect(): OperatingSystem {
return OperatingSystem.BlackBerryTabletOS;
}
}

View File

@@ -0,0 +1,13 @@
import { ICommandOps } from '@/infrastructure/Environment/SystemOperations/ISystemOperations';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
export class CommandOpsStub
extends StubWithObservableMethodCalls<ICommandOps>
implements ICommandOps {
public execute(command: string): void {
this.registerMethodCall({
methodName: 'execute',
args: [command],
});
}
}

View File

@@ -1,13 +1,22 @@
import { IEnvironment } from '@/application/Environment/IEnvironment';
import { IEnvironment } from '@/infrastructure/Environment/IEnvironment';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { ISystemOperations } from '@/infrastructure/Environment/SystemOperations/ISystemOperations';
import { SystemOperationsStub } from './SystemOperationsStub';
export class EnvironmentStub implements IEnvironment {
public isDesktop = true;
public os = OperatingSystem.Windows;
public withOs(os: OperatingSystem): EnvironmentStub {
public system: ISystemOperations = new SystemOperationsStub();
public withOs(os: OperatingSystem): this {
this.os = os;
return this;
}
public withSystemOperations(system: ISystemOperations): this {
this.system = system;
return this;
}
}

View File

@@ -0,0 +1,30 @@
import { IFileSystemOps } from '@/infrastructure/Environment/SystemOperations/ISystemOperations';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
export class FileSystemOpsStub
extends StubWithObservableMethodCalls<IFileSystemOps>
implements IFileSystemOps {
public setFilePermissions(filePath: string, mode: string | number): Promise<void> {
this.registerMethodCall({
methodName: 'setFilePermissions',
args: [filePath, mode],
});
return Promise.resolve();
}
public createDirectory(directoryPath: string, isRecursive?: boolean): Promise<string> {
this.registerMethodCall({
methodName: 'createDirectory',
args: [directoryPath, isRecursive],
});
return Promise.resolve(directoryPath);
}
public writeToFile(filePath: string, data: string): Promise<void> {
this.registerMethodCall({
methodName: 'writeToFile',
args: [filePath, data],
});
return Promise.resolve();
}
}

View File

@@ -0,0 +1,40 @@
import { ILocationOps } from '@/infrastructure/Environment/SystemOperations/ISystemOperations';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
export class LocationOpsStub
extends StubWithObservableMethodCalls<ILocationOps>
implements ILocationOps {
private sequence = new Array<string>();
private scenarios = new Map<string, string>();
public withJoinResult(returnValue: string, ...paths: string[]): this {
this.scenarios.set(LocationOpsStub.getScenarioKey(paths), returnValue);
return this;
}
public withJoinResultSequence(...valuesToReturn: string[]): this {
this.sequence.push(...valuesToReturn);
this.sequence.reverse();
return this;
}
public combinePaths(...pathSegments: string[]): string {
this.registerMethodCall({
methodName: 'combinePaths',
args: pathSegments,
});
if (this.sequence.length > 0) {
return this.sequence.pop();
}
const key = LocationOpsStub.getScenarioKey(pathSegments);
if (!this.scenarios.has(key)) {
return pathSegments.join('/PATH-SEGMENT-SEPARATOR/');
}
return this.scenarios.get(key);
}
private static getScenarioKey(paths: string[]): string {
return paths.join('|');
}
}

View File

@@ -0,0 +1,21 @@
import { IOperatingSystemOps } from '@/infrastructure/Environment/SystemOperations/ISystemOperations';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
export class OperatingSystemOpsStub
extends StubWithObservableMethodCalls<IOperatingSystemOps>
implements IOperatingSystemOps {
private temporaryDirectory = '/stub-temp-dir/';
public withTemporaryDirectoryResult(directory: string): this {
this.temporaryDirectory = directory;
return this;
}
public getTempDirectory(): string {
this.registerMethodCall({
methodName: 'getTempDirectory',
args: [],
});
return this.temporaryDirectory;
}
}

View File

@@ -1,10 +1,17 @@
import { ISanityCheckOptions } from '@/infrastructure/RuntimeSanity/ISanityCheckOptions';
import { ISanityCheckOptions } from '@/infrastructure/RuntimeSanity/Common/ISanityCheckOptions';
export class SanityCheckOptionsStub implements ISanityCheckOptions {
public validateEnvironment = false;
public validateMetadata = false;
public withValidateMetadata(value: boolean): this {
this.validateMetadata = value;
return this;
}
public withValidateEnvironment(value: boolean): this {
this.validateEnvironment = value;
return this;
}
}

View File

@@ -1,9 +1,11 @@
import { ISanityCheckOptions } from '@/infrastructure/RuntimeSanity/ISanityCheckOptions';
import { ISanityValidator } from '@/infrastructure/RuntimeSanity/ISanityValidator';
import { ISanityCheckOptions } from '@/infrastructure/RuntimeSanity/Common/ISanityCheckOptions';
import { ISanityValidator } from '@/infrastructure/RuntimeSanity/Common/ISanityValidator';
export class SanityValidatorStub implements ISanityValidator {
public shouldValidateArgs = new Array<ISanityCheckOptions>();
public name = 'sanity-validator-stub';
private errors: readonly string[] = [];
private shouldValidateResult = true;
@@ -17,6 +19,11 @@ export class SanityValidatorStub implements ISanityValidator {
return this.errors;
}
public withName(name: string): this {
this.name = name;
return this;
}
public withErrorsResult(errors: readonly string[]): this {
this.errors = errors;
return this;

View File

@@ -0,0 +1,25 @@
import { EventSource } from '@/infrastructure/Events/EventSource';
import { IEventSource } from '@/infrastructure/Events/IEventSource';
import { FunctionKeys } from '@/TypeHelpers';
export abstract class StubWithObservableMethodCalls<T> {
public readonly callHistory = new Array<MethodCall<T>>();
public get methodCalls(): IEventSource<MethodCall<T>> {
return this.notifiableMethodCalls;
}
private readonly notifiableMethodCalls = new EventSource<MethodCall<T>>();
protected registerMethodCall(name: MethodCall<T>) {
this.callHistory.push(name);
this.notifiableMethodCalls.notify(name);
}
}
type MethodCall<T> = {
[K in FunctionKeys<T>]: {
methodName: K;
args: T[K] extends (...args: infer A) => unknown ? A : never;
}
}[FunctionKeys<T>];

View File

@@ -0,0 +1,41 @@
import {
ICommandOps,
IFileSystemOps,
IOperatingSystemOps,
ILocationOps,
ISystemOperations,
} from '@/infrastructure/Environment/SystemOperations/ISystemOperations';
import { CommandOpsStub } from './CommandOpsStub';
import { FileSystemOpsStub } from './FileSystemOpsStub';
import { LocationOpsStub } from './LocationOpsStub';
import { OperatingSystemOpsStub } from './OperatingSystemOpsStub';
export class SystemOperationsStub implements ISystemOperations {
public operatingSystem: IOperatingSystemOps = new OperatingSystemOpsStub();
public location: ILocationOps = new LocationOpsStub();
public fileSystem: IFileSystemOps = new FileSystemOpsStub();
public command: ICommandOps = new CommandOpsStub();
public withOperatingSystem(operatingSystemOps: IOperatingSystemOps): this {
this.operatingSystem = operatingSystemOps;
return this;
}
public withLocation(location: ILocationOps): this {
this.location = location;
return this;
}
public withFileSystem(fileSystem: IFileSystemOps): this {
this.fileSystem = fileSystem;
return this;
}
public withCommand(command: ICommandOps): this {
this.command = command;
return this;
}
}

View File

@@ -1,17 +1,24 @@
import { it } from 'vitest';
export function itEachAbsentStringValue(runner: (absentValue: string) => void): void {
itEachAbsentTestCase(AbsentStringTestCases, runner);
export function itEachAbsentStringValue(
runner: (absentValue: string) => void,
options: IAbsentTestCaseOptions = DefaultAbsentTestCaseOptions,
): void {
itEachAbsentTestCase(getAbsentStringTestCases(options), runner);
}
export function itEachAbsentObjectValue(
runner: (absentValue: AbsentObjectType) => void,
options: IAbsentTestCaseOptions = DefaultAbsentTestCaseOptions,
): void {
itEachAbsentTestCase(AbsentObjectTestCases, runner);
itEachAbsentTestCase(getAbsentObjectTestCases(options), runner);
}
export function itEachAbsentCollectionValue<T>(runner: (absentValue: []) => void): void {
itEachAbsentTestCase(getAbsentCollectionTestCases<T>(), runner);
export function itEachAbsentCollectionValue<T>(
runner: (absentValue: []) => void,
options: IAbsentTestCaseOptions = DefaultAbsentTestCaseOptions,
): void {
itEachAbsentTestCase(getAbsentCollectionTestCases<T>(options), runner);
}
export function itEachAbsentTestCase<T>(
@@ -25,28 +32,40 @@ export function itEachAbsentTestCase<T>(
}
}
export const AbsentObjectTestCases: readonly IAbsentTestCase<AbsentObjectType>[] = [
{
valueName: 'undefined',
absentValue: undefined,
},
{
valueName: 'null',
absentValue: null,
},
];
export const AbsentStringTestCases: readonly IAbsentStringCase[] = [
{
valueName: 'empty',
absentValue: '',
},
...AbsentObjectTestCases,
];
export function getAbsentCollectionTestCases<T>(): readonly IAbsentCollectionCase<T>[] {
export function getAbsentObjectTestCases(
options: IAbsentTestCaseOptions = DefaultAbsentTestCaseOptions,
): IAbsentTestCase<AbsentObjectType>[] {
return [
...AbsentObjectTestCases,
{
valueName: 'null',
absentValue: null,
},
...(options.excludeUndefined ? [] : [
{
valueName: 'undefined',
absentValue: undefined,
},
]),
];
}
export function getAbsentStringTestCases(
options: IAbsentTestCaseOptions = DefaultAbsentTestCaseOptions,
): IAbsentStringCase[] {
return [
{
valueName: 'empty',
absentValue: '',
},
...getAbsentObjectTestCases(options),
];
}
export function getAbsentCollectionTestCases<T>(
options: IAbsentTestCaseOptions = DefaultAbsentTestCaseOptions,
): readonly IAbsentCollectionCase<T>[] {
return [
...getAbsentObjectTestCases(options),
{
valueName: 'empty',
absentValue: new Array<T>(),
@@ -54,6 +73,14 @@ export function getAbsentCollectionTestCases<T>(): readonly IAbsentCollectionCas
];
}
const DefaultAbsentTestCaseOptions: IAbsentTestCaseOptions = {
excludeUndefined: false,
};
interface IAbsentTestCaseOptions {
readonly excludeUndefined: boolean;
}
type AbsentObjectType = undefined | null;
interface IAbsentTestCase<T> {