summaryrefslogtreecommitdiffhomepage
path: root/gui/src
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2021-04-15 15:44:39 +0200
committerOskar Nyberg <oskar@mullvad.net>2021-04-15 15:44:39 +0200
commit87bdd6dcf7545a4c31d0b34de2f21f1c3310447c (patch)
tree6d66133d6695cc2e741b4f5b6867b888a0776b7d /gui/src
parent1cea60f95c613e007cf734ed9be43b2a0b28fde4 (diff)
parentba86c078f01fa52d920c32781ac2ee85e9cd1581 (diff)
downloadmullvadvpn-87bdd6dcf7545a4c31d0b34de2f21f1c3310447c.tar.xz
mullvadvpn-87bdd6dcf7545a4c31d0b34de2f21f1c3310447c.zip
Merge branch 'get-installed-apps-windows'
Diffstat (limited to 'gui/src')
-rw-r--r--gui/src/main/gui-settings.ts42
-rw-r--r--gui/src/main/windows-pe-parser.ts379
-rw-r--r--gui/src/main/windows-split-tunneling.ts350
-rw-r--r--gui/src/renderer/redux/settings/reducers.ts1
-rw-r--r--gui/src/shared/gui-settings-state.ts4
5 files changed, 769 insertions, 7 deletions
diff --git a/gui/src/main/gui-settings.ts b/gui/src/main/gui-settings.ts
index c7f15df75e..753f767bde 100644
--- a/gui/src/main/gui-settings.ts
+++ b/gui/src/main/gui-settings.ts
@@ -11,6 +11,7 @@ const settingsSchema = {
monochromaticIcon: 'boolean',
startMinimized: 'boolean',
unpinnedWindow: 'boolean',
+ browsedForSplitTunnelingApplications: 'Array<string>',
};
const defaultSettings: IGuiSettingsState = {
@@ -20,9 +21,14 @@ const defaultSettings: IGuiSettingsState = {
monochromaticIcon: false,
startMinimized: false,
unpinnedWindow: process.platform !== 'win32' && process.platform !== 'darwin',
+ browsedForSplitTunnelingApplications: [],
};
export default class GuiSettings {
+ public onChange?: (newState: IGuiSettingsState, oldState: IGuiSettingsState) => void;
+
+ private stateValue: IGuiSettingsState = { ...defaultSettings };
+
get state(): IGuiSettingsState {
return this.stateValue;
}
@@ -75,9 +81,16 @@ export default class GuiSettings {
return this.stateValue.unpinnedWindow;
}
- public onChange?: (newState: IGuiSettingsState, oldState: IGuiSettingsState) => void;
+ public addBrowsedForSplitTunnelingapplications(newApp: string) {
+ this.changeStateAndNotify({
+ ...this.stateValue,
+ browsedForSplitTunnelingApplications: [...this.browsedForSplitTunnelingApplications, newApp],
+ });
+ }
- private stateValue: IGuiSettingsState = { ...defaultSettings };
+ get browsedForSplitTunnelingApplications(): Array<string> {
+ return this.stateValue.browsedForSplitTunnelingApplications;
+ }
public load() {
try {
@@ -110,12 +123,27 @@ export default class GuiSettings {
}
}
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- private validateSettings(settings: any) {
+ private validateSettings(settings: Record<string, unknown>) {
Object.entries(settingsSchema).forEach(([key, expectedType]) => {
- const actualType = typeof settings[key];
- if (key in settings && actualType !== expectedType) {
- throw new Error(`Expected ${key} to be of type ${expectedType} but was ${actualType}`);
+ if (/^Array<.*>/.test(expectedType)) {
+ const value = settings[key];
+ if (!Array.isArray(value)) {
+ throw new Error(`Expected ${key} to be array but wasn't`);
+ } else {
+ const expectedInnerType = expectedType.replace(/^Array</, '').replace(/>$/, '');
+ const innerTypes: string[] = value.map((value) => typeof value);
+ if (
+ innerTypes.some((value) => value !== innerTypes[0]) ||
+ innerTypes[0] !== expectedInnerType
+ ) {
+ throw new Error(`Expected ${key} to to contain ${expectedInnerType}s`);
+ }
+ }
+ } else {
+ const actualType = typeof settings[key];
+ if (key in settings && actualType !== expectedType) {
+ throw new Error(`Expected ${key} to be of type ${expectedType} but was ${actualType}`);
+ }
}
});
diff --git a/gui/src/main/windows-pe-parser.ts b/gui/src/main/windows-pe-parser.ts
new file mode 100644
index 0000000000..5dfedf5a44
--- /dev/null
+++ b/gui/src/main/windows-pe-parser.ts
@@ -0,0 +1,379 @@
+import { promises as fs } from 'fs';
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type Primitive = { size: number; reader: (buffer: Buffer) => any };
+export type PrimitiveWrapper = { primitive: Primitive };
+
+export type StructItem = { name: string; datatype: Datatype };
+export type Struct = Array<StructItem>;
+export type StructWrapper = { struct: Struct };
+
+export type ArrayWrapper = { array: Array<Datatype> };
+
+export type Datatype = PrimitiveWrapper | StructWrapper | ArrayWrapper;
+
+// Type that represent the correct value-type for a given Datatype.
+type ValueType<T extends Datatype> = T extends PrimitiveWrapper
+ ? PrimitiveValue<T>
+ : T extends ArrayWrapper
+ ? ArrayValue<T>
+ : T extends StructWrapper
+ ? StructValue<T>
+ : never;
+
+// Represents any kind of parseable value within the PE headers. Value is extended by
+// PrimitiveValue, ArrayValue and StructValue.
+export class Value<T extends Datatype> {
+ public constructor(
+ protected fileHandle: fs.FileHandle,
+ public readonly buffer: Buffer,
+ public readonly datatype: T,
+ public readonly offset: number,
+ ) {}
+
+ public get size(): number {
+ return Value.sizeOf(this.datatype);
+ }
+
+ public get endOffset(): number {
+ return this.offset + this.size;
+ }
+
+ public static sizeOf(datatype: Datatype): number {
+ if (Value.isPrimitive(datatype)) {
+ return datatype.primitive.size;
+ } else if (Value.isArray(datatype)) {
+ return datatype.array.reduce((sum, current) => sum + Value.sizeOf(current), 0);
+ } else if (Value.isStruct(datatype)) {
+ return datatype.struct.reduce((sum, current) => sum + Value.sizeOf(current.datatype), 0);
+ } else {
+ throw new Error('Not possible');
+ }
+ }
+
+ // Reads a datatype from a file handle and returns the correct subclass of Value.
+ public static async fromFile<T extends Datatype>(
+ fileHandle: fs.FileHandle,
+ offset: number,
+ datatype: T,
+ ): Promise<ValueType<T>> {
+ const buffer = Buffer.alloc(Value.sizeOf(datatype));
+ const { bytesRead } = await fileHandle.read(buffer, 0, buffer.length, offset);
+
+ if (bytesRead < buffer.length) {
+ throw new Error('Failed to read datatype');
+ }
+
+ return Value.createNew(fileHandle, buffer, datatype, offset);
+ }
+
+ protected static isPrimitive(datatype: Datatype): datatype is PrimitiveWrapper {
+ return 'primitive' in datatype;
+ }
+ protected static isArray(datatype: Datatype): datatype is ArrayWrapper {
+ return 'array' in datatype;
+ }
+ protected static isStruct(datatype: Datatype): datatype is StructWrapper {
+ return 'struct' in datatype;
+ }
+
+ protected createNew<T extends Datatype>(
+ buffer: Buffer,
+ datatype: T,
+ offset: number,
+ ): ValueType<T> {
+ return Value.createNew(this.fileHandle, buffer, datatype, offset);
+ }
+
+ private static createNew<T extends Datatype>(
+ fileHandle: fs.FileHandle,
+ buffer: Buffer,
+ datatype: T,
+ offset: number,
+ ): ValueType<T> {
+ if (Value.isPrimitive(datatype)) {
+ return new PrimitiveValue(fileHandle, buffer, datatype, offset) as ValueType<T>;
+ } else if (Value.isArray(datatype)) {
+ return new ArrayValue(fileHandle, buffer, datatype, offset) as ValueType<T>;
+ } else if (Value.isStruct(datatype)) {
+ return new StructValue(fileHandle, buffer, datatype, offset) as ValueType<T>;
+ } else {
+ // This will never happen since the value can't be anything else than the above types.
+ throw new Error('No matching value type.');
+ }
+ }
+}
+
+// Calculates the byteoffset from a relative virtual address.
+export async function rvaToOffset(
+ fileHandle: fs.FileHandle,
+ rva: number,
+ numberOfSections: number,
+ firstSectionHeaderOffset: number,
+): Promise<number> {
+ for (let i = 0; i < numberOfSections; i++) {
+ const sectionHeaderOffset = firstSectionHeaderOffset + i * Value.sizeOf(IMAGE_SECTION_HEADER);
+ const sectionHeader = await Value.fromFile(
+ fileHandle,
+ sectionHeaderOffset,
+ IMAGE_SECTION_HEADER,
+ );
+ const sectionRva = sectionHeader.get('VirtualAddress').value<number>();
+ const sectionSize = sectionHeader.get('SizeOfRawData').value<number>();
+
+ if (rva >= sectionRva && rva < sectionRva + sectionSize) {
+ const pointerToRawData = sectionHeader.get('PointerToRawData').value();
+ return pointerToRawData + (rva - sectionRva);
+ }
+ }
+
+ throw new Error('Failed to map RVA to offset');
+}
+
+export class PrimitiveValue<T extends PrimitiveWrapper = PrimitiveWrapper> extends Value<T> {
+ // Parses and returns the value.
+ public value<U extends ReturnType<T['primitive']['reader']>>(): ReturnType<
+ T['primitive']['reader']
+ > {
+ const result = this.datatype.primitive.reader(this.buffer);
+ if (result === undefined) {
+ throw new Error('Failed to read value from buffer');
+ } else {
+ return result as U;
+ }
+ }
+}
+
+export class ArrayValue<T extends ArrayWrapper = ArrayWrapper> extends Value<T> {
+ // Parses and returns the value at the specified index.
+ public nth<U extends ValueType<T['array'][number]>>(index: number): U {
+ const datatype = this.datatype.array[0];
+ const itemSize = Value.sizeOf(datatype);
+ const offset = index * itemSize;
+ const buffer = this.buffer.slice(offset, offset + itemSize);
+
+ return this.createNew(buffer, datatype, offset) as U;
+ }
+}
+
+export class StructValue<T extends StructWrapper = StructWrapper> extends Value<T> {
+ // Parses and returns the value for the specified key.
+ public get<
+ U extends ValueType<T['struct'][number]['datatype']>,
+ V extends StructItem = T['struct'][number]
+ >(name: V['name']): U {
+ const index = this.datatype.struct.findIndex((entry) => entry.name === name);
+ if (index === -1) {
+ throw new Error('No such field');
+ }
+
+ const datatype = this.datatype.struct[index].datatype;
+
+ const slicedType = { struct: this.datatype.struct.slice(0, index) };
+ const offset = Value.sizeOf(slicedType);
+ const size = Value.sizeOf(datatype);
+ const buffer = this.buffer.slice(offset, offset + size);
+
+ return this.createNew(buffer, datatype, offset) as U;
+ }
+}
+
+// Datatype specifications
+export const ARRAY = <T>(length: number, innerType: T) => ({
+ array: Array<T>(length).fill(innerType),
+});
+export const USHORT = {
+ primitive: { size: 2, reader: (buffer: Buffer) => buffer.readUInt16LE(0) },
+};
+export const LONG = {
+ primitive: { size: 4, reader: (buffer: Buffer) => buffer.readUInt32LE(0) },
+};
+export const ULONGLONG = {
+ primitive: {
+ size: 8,
+ reader: (_buffer: Buffer) => {
+ throw new Error('Not implemented');
+ },
+ },
+};
+export const WORD = {
+ primitive: { size: 2, reader: (buffer: Buffer) => buffer.readUInt16LE(0) },
+};
+export const DWORD = {
+ primitive: { size: 4, reader: (buffer: Buffer) => buffer.readUInt32LE(0) },
+};
+export const BYTE = {
+ primitive: { size: 1, reader: (buffer: Buffer) => buffer.readInt8(0) },
+};
+export const UTF8_STRING = (length: number) => ({
+ primitive: {
+ size: length,
+ reader: (_buffer: Buffer) => {
+ throw new Error('Not implemented');
+ },
+ },
+});
+
+export const IMAGE_FILE_HEADER = {
+ struct: [
+ { name: 'Machine', datatype: WORD },
+ { name: 'NumberOfSections', datatype: WORD },
+ { name: 'TimeDateStamp', datatype: DWORD },
+ { name: 'PointerToSymbolTable', datatype: DWORD },
+ { name: 'NumberOfSymbols', datatype: DWORD },
+ { name: 'SizeOfOptionalHeader', datatype: WORD },
+ { name: 'Characteristics', datatype: WORD },
+ ],
+};
+
+export const IMAGE_DATA_DIRECTORY_ENTRY = {
+ struct: [
+ { name: 'VirtualAddress', datatype: DWORD },
+ { name: 'Size', datatype: DWORD },
+ ],
+};
+
+export const IMAGE_DATA_DIRECTORY = ARRAY(16, IMAGE_DATA_DIRECTORY_ENTRY);
+
+export const IMAGE_OPTIONAL_HEADER32 = {
+ struct: [
+ { name: 'Magic', datatype: WORD },
+ { name: 'MajorLinkerVersion', datatype: BYTE },
+ { name: 'MinorLinkerVersion', datatype: BYTE },
+ { name: 'SizeOfCode', datatype: DWORD },
+ { name: 'SizeOfInitializedData', datatype: DWORD },
+ { name: 'SizeOfUninitializedData', datatype: DWORD },
+ { name: 'AddressOfEntryPoint', datatype: DWORD },
+ { name: 'BaseOfCode', datatype: DWORD },
+ { name: 'BaseOfData', datatype: DWORD },
+ { name: 'ImageBase', datatype: DWORD },
+ { name: 'SectionAlignment', datatype: DWORD },
+ { name: 'FileAlignment', datatype: DWORD },
+ { name: 'MajorOperatingSystemVersion', datatype: WORD },
+ { name: 'MinorOperatingSystemVersion', datatype: WORD },
+ { name: 'MajorImageVersion', datatype: WORD },
+ { name: 'MinorImageVersion', datatype: WORD },
+ { name: 'MajorSubsystemVersion', datatype: WORD },
+ { name: 'MinorSubsystemVersion', datatype: WORD },
+ { name: 'Win32VersionValue', datatype: DWORD },
+ { name: 'SizeOfImage', datatype: DWORD },
+ { name: 'SizeOfHeaders', datatype: DWORD },
+ { name: 'CheckSum', datatype: DWORD },
+ { name: 'Subsystem', datatype: WORD },
+ { name: 'DllCharacteristics', datatype: WORD },
+ { name: 'SizeOfStackReserve', datatype: DWORD },
+ { name: 'SizeOfStackCommit', datatype: DWORD },
+ { name: 'SizeOfHeapReserve', datatype: DWORD },
+ { name: 'SizeOfHeapCommit', datatype: DWORD },
+ { name: 'LoaderFlags', datatype: DWORD },
+ { name: 'NumberOfRvaAndSizes', datatype: DWORD },
+ { name: 'DataDirectory', datatype: IMAGE_DATA_DIRECTORY },
+ ],
+};
+
+export const IMAGE_OPTIONAL_HEADER64 = {
+ struct: [
+ { name: 'Magic', datatype: WORD },
+ { name: 'MajorLinkerVersion', datatype: BYTE },
+ { name: 'MinorLinkerVersion', datatype: BYTE },
+ { name: 'SizeOfCode', datatype: DWORD },
+ { name: 'SizeOfInitializedData', datatype: DWORD },
+ { name: 'SizeOfUninitializedData', datatype: DWORD },
+ { name: 'AddressOfEntryPoint', datatype: DWORD },
+ { name: 'BaseOfCode', datatype: DWORD },
+ { name: 'ImageBase', datatype: ULONGLONG },
+ { name: 'SectionAlignment', datatype: DWORD },
+ { name: 'FileAlignment', datatype: DWORD },
+ { name: 'MajorOperatingSystemVersion', datatype: WORD },
+ { name: 'MinorOperatingSystemVersion', datatype: WORD },
+ { name: 'MajorImageVersion', datatype: WORD },
+ { name: 'MinorImageVersion', datatype: WORD },
+ { name: 'MajorSubsystemVersion', datatype: WORD },
+ { name: 'MinorSubsystemVersion', datatype: WORD },
+ { name: 'Win32VersionValue', datatype: DWORD },
+ { name: 'SizeOfImage', datatype: DWORD },
+ { name: 'SizeOfHeaders', datatype: DWORD },
+ { name: 'CheckSum', datatype: DWORD },
+ { name: 'Subsystem', datatype: WORD },
+ { name: 'DllCharacteristics', datatype: WORD },
+ { name: 'SizeOfStackReserve', datatype: ULONGLONG },
+ { name: 'SizeOfStackCommit', datatype: ULONGLONG },
+ { name: 'SizeOfHeapReserve', datatype: ULONGLONG },
+ { name: 'SizeOfHeapCommit', datatype: ULONGLONG },
+ { name: 'LoaderFlags', datatype: DWORD },
+ { name: 'NumberOfRvaAndSizes', datatype: DWORD },
+ { name: 'DataDirectory', datatype: IMAGE_DATA_DIRECTORY },
+ ],
+};
+
+export const IMAGE_NT_HEADERS = {
+ struct: [
+ { name: 'Signature', datatype: DWORD },
+ { name: 'FileHeader', datatype: IMAGE_FILE_HEADER },
+ { name: 'OptionalHeader', datatype: IMAGE_OPTIONAL_HEADER32 },
+ ],
+};
+
+export const IMAGE_NT_HEADERS64 = {
+ struct: [
+ { name: 'Signature', datatype: DWORD },
+ { name: 'FileHeader', datatype: IMAGE_FILE_HEADER },
+ { name: 'OptionalHeader', datatype: IMAGE_OPTIONAL_HEADER64 },
+ ],
+};
+
+export const DOS_HEADER = {
+ struct: [
+ { name: 'e_magic', datatype: USHORT },
+ { name: 'e_cblp', datatype: USHORT },
+ { name: 'e_cp', datatype: USHORT },
+ { name: 'e_crlc', datatype: USHORT },
+ { name: 'e_cparhdr', datatype: USHORT },
+ { name: 'e_minalloc', datatype: USHORT },
+ { name: 'e_maxalloc', datatype: USHORT },
+ { name: 'e_ss', datatype: USHORT },
+ { name: 'e_sp', datatype: USHORT },
+ { name: 'e_csum', datatype: USHORT },
+ { name: 'e_ip', datatype: USHORT },
+ { name: 'e_cs', datatype: USHORT },
+ { name: 'e_lfarlc', datatype: USHORT },
+ { name: 'e_ovno', datatype: USHORT },
+ { name: 'e_res', datatype: ARRAY(4, USHORT) },
+ { name: 'e_oemid', datatype: USHORT },
+ { name: 'e_oeminfo', datatype: USHORT },
+ { name: 'e_res2', datatype: ARRAY(10, USHORT) },
+ { name: 'e_lfanew', datatype: LONG },
+ ],
+};
+
+export const IMAGE_SECTION_HEADER = {
+ struct: [
+ { name: 'Name', datatype: UTF8_STRING(8) },
+ { name: 'PhysicalAddressVirtualSizeUnion', datatype: DWORD }, // TODO? Support unions?
+ { name: 'VirtualAddress', datatype: DWORD },
+ { name: 'SizeOfRawData', datatype: DWORD },
+ { name: 'PointerToRawData', datatype: DWORD },
+ { name: 'PointerToRelocations', datatype: DWORD },
+ { name: 'PointerToLinenumbers', datatype: DWORD },
+ { name: 'NumberOfRelocations', datatype: WORD },
+ { name: 'NumberOfLinenumbers', datatype: WORD },
+ { name: 'Characteristics', datatype: DWORD },
+ ],
+};
+
+export const IMAGE_IMPORT_MODULE_DIRECTORY = {
+ struct: [
+ { name: 'ImportLookupTable', datatype: DWORD },
+ { name: 'TimeDateStamp', datatype: DWORD },
+ { name: 'ForwarderChain', datatype: DWORD },
+ { name: 'ModuleName', datatype: DWORD },
+ { name: 'ImportAddressTable', datatype: DWORD },
+ ],
+};
+
+export const IMAGE_DIRECTORY_ENTRY_IMPORT = 1;
+
+export type ImageNtHeadersUnion = typeof IMAGE_NT_HEADERS | typeof IMAGE_NT_HEADERS64;
+export type ImageOptionalHeaderUnion =
+ | typeof IMAGE_OPTIONAL_HEADER32
+ | typeof IMAGE_OPTIONAL_HEADER64;
diff --git a/gui/src/main/windows-split-tunneling.ts b/gui/src/main/windows-split-tunneling.ts
new file mode 100644
index 0000000000..c07f9f2f7c
--- /dev/null
+++ b/gui/src/main/windows-split-tunneling.ts
@@ -0,0 +1,350 @@
+import { app, shell } from 'electron';
+import fs from 'fs';
+import path from 'path';
+import { IApplication } from '../shared/application-types';
+import log from '../shared/logging';
+import {
+ ArrayValue,
+ DOS_HEADER,
+ IMAGE_DATA_DIRECTORY,
+ IMAGE_DIRECTORY_ENTRY_IMPORT,
+ IMAGE_FILE_HEADER,
+ IMAGE_IMPORT_MODULE_DIRECTORY,
+ IMAGE_NT_HEADERS,
+ IMAGE_NT_HEADERS64,
+ ImageNtHeadersUnion,
+ IMAGE_OPTIONAL_HEADER32,
+ ImageOptionalHeaderUnion,
+ PrimitiveValue,
+ rvaToOffset,
+ StructValue,
+ Value,
+ DWORD,
+} from './windows-pe-parser';
+
+interface ShortcutDetails {
+ target: string;
+ name: string;
+ args?: string;
+}
+
+// Applications are found by scanning the start menu directories
+const APPLICATION_PATHS = [
+ `${process.env.ProgramData}/Microsoft/Windows/Start Menu/Programs`,
+ `${process.env.AppData}/Microsoft/Windows/Start Menu/Programs`,
+];
+
+// Some applications might be falsely filtered from the application list. This allow-list specifies
+// apps that are falsely filtered but should be included.
+const APPLICATION_ALLOW_LIST = ['firefox.exe', 'chrome.exe'];
+
+// Cache of all previously scanned shortcuts.
+const shortcutCache: Record<string, ShortcutDetails> = {};
+// Cache of all previously scanned applications.
+const applicationCache: Record<string, IApplication> = {};
+// List of shortcuts that have been added manually by the user.
+const additionalShortcuts: ShortcutDetails[] = [];
+
+// Finds applications by searching through the startmenu for shortcuts with and exe-file as target.
+// If applicationPaths has a value, the returned applications are only the ones corresponding to
+// those paths.
+export async function getApplications(options: {
+ applicationPaths?: string[];
+ updateCaches?: boolean;
+}): Promise<{ fromCache: boolean; applications: IApplication[] }> {
+ const cacheIsEmpty = Object.keys(shortcutCache).length === 0;
+
+ if (options.updateCaches || cacheIsEmpty) {
+ await updateShortcutCache();
+ }
+
+ // Add excluded apps that are missing from the shortcut cache to it
+ options.applicationPaths?.forEach(addApplicationToAdditionalShortcuts);
+
+ await updateApplicationCache();
+ // If applicationPaths is supplied the returnvalue should only contain the applications
+ // corresponding to those paths.
+ const applications = Object.values(applicationCache)
+ .filter(
+ (application) =>
+ options.applicationPaths === undefined ||
+ options.applicationPaths.includes(application.absolutepath),
+ )
+ .sort((a, b) => a.name.localeCompare(b.name));
+
+ return {
+ fromCache: !options.updateCaches && !cacheIsEmpty,
+ applications,
+ };
+}
+
+// Adds either a shortcut or an executable to the additionalShortcuts list
+export function addApplicationPathToCache(applicationPath: string): string {
+ const parsedPath = path.parse(applicationPath);
+ if (parsedPath.ext === '.lnk') {
+ const shortcutDetiails = shell.readShortcutLink(path.resolve(applicationPath));
+ additionalShortcuts.push({ ...shortcutDetiails, name: path.parse(applicationPath).name });
+ return shortcutDetiails.target;
+ } else {
+ addApplicationToAdditionalShortcuts(applicationPath);
+ return applicationPath;
+ }
+}
+
+// Reads the start-menu directories and adds all shortcuts, targeting applications using networking,
+// to the shortcuts cache. Wheter or not an application use networking is determined by checking for
+// "WS2_32.dll" in it's imports.
+async function updateShortcutCache(): Promise<void> {
+ const links = await Promise.all(APPLICATION_PATHS.map(findAllLinks));
+ const resolvedLinks = removeDuplicates(resolveLinks(links.flat()));
+
+ const shortcuts: ShortcutDetails[] = [];
+ for (const shortcut of resolvedLinks) {
+ if (
+ APPLICATION_ALLOW_LIST.includes(path.basename(shortcut.target)) ||
+ (await importsDll(shortcut.target, 'WS2_32.dll'))
+ ) {
+ shortcuts.push(shortcut);
+ shortcutCache[shortcut.target] = shortcut;
+ }
+ }
+}
+
+async function updateApplicationCache(): Promise<void> {
+ const shortcuts = Object.values(shortcutCache).concat(additionalShortcuts);
+
+ await Promise.all(
+ shortcuts.map(async (shortcut) => {
+ if (applicationCache[shortcut.target] === undefined) {
+ applicationCache[shortcut.target] = await convertToSplitTunnelingApplication(shortcut);
+ }
+
+ return applicationCache[shortcut.target];
+ }),
+ );
+}
+
+// Add excluded apps that are missing from the shortcut cache to it
+function addApplicationToAdditionalShortcuts(applicationPath: string): void {
+ if (
+ shortcutCache[applicationPath] === undefined &&
+ !additionalShortcuts.some((shortcut) => shortcut.target === applicationPath)
+ ) {
+ additionalShortcuts.push({
+ target: applicationPath,
+ name: path.parse(applicationPath).name,
+ });
+ }
+}
+
+// Fins all links in a directory.
+async function findAllLinks(path: string): Promise<string[]> {
+ if (path.endsWith('.lnk')) {
+ return [path];
+ } else {
+ const stat = await fs.promises.stat(path);
+ if (stat.isDirectory()) {
+ const contents = await fs.promises.readdir(path);
+ const result = await Promise.all(contents.map((item) => findAllLinks(`${path}/${item}`)));
+ return result.flat();
+ } else {
+ return [];
+ }
+ }
+}
+
+function resolveLinks(linkPaths: string[]): ShortcutDetails[] {
+ return linkPaths
+ .map((link) => {
+ try {
+ return {
+ ...shell.readShortcutLink(path.resolve(link)),
+ name: path.parse(link).name,
+ };
+ } catch (_e) {
+ return null;
+ }
+ })
+ .filter(
+ (shortcut): shortcut is ShortcutDetails =>
+ shortcut !== null &&
+ shortcut.name !== 'Mullvad VPN' &&
+ shortcut.target.endsWith('.exe') &&
+ !shortcut.target.toLowerCase().includes('uninstall') &&
+ !shortcut.name.toLowerCase().includes('uninstall'),
+ );
+}
+
+// Removes all duplicate shortcuts.
+function removeDuplicates(shortcuts: ShortcutDetails[]): ShortcutDetails[] {
+ const unique = shortcuts.reduce((shortcuts, shortcut) => {
+ if (shortcuts[shortcut.target]) {
+ if (
+ shortcuts[shortcut.target].args &&
+ shortcuts[shortcut.target].args !== '' &&
+ (!shortcut.args || shortcut.args === '')
+ ) {
+ shortcuts[shortcut.target] = shortcut;
+ }
+ } else {
+ shortcuts[shortcut.target] = shortcut;
+ }
+ return shortcuts;
+ }, {} as Record<string, ShortcutDetails>);
+
+ return Object.values(unique);
+}
+
+async function convertToSplitTunnelingApplication(
+ shortcut: ShortcutDetails,
+): Promise<IApplication> {
+ return {
+ absolutepath: shortcut.target,
+ name: shortcut.name,
+ icon: await retrieveIcon(shortcut.target),
+ };
+}
+
+async function retrieveIcon(exe: string) {
+ const icon = await app.getFileIcon(exe);
+ return icon.toDataURL();
+}
+
+// Checks if the application at the supplied path imports a specific dll.
+async function importsDll(path: string, dllName: string): Promise<boolean> {
+ let fileHandle: fs.promises.FileHandle;
+ try {
+ fileHandle = await fs.promises.open(path, fs.constants.O_RDONLY);
+ } catch (e) {
+ log.error('Failed to create file handle.', e);
+ return false;
+ }
+
+ const imports = await getExeImports(fileHandle, path);
+ await fileHandle.close();
+ return imports.map((name) => name.toLowerCase()).includes(dllName.toLowerCase());
+}
+
+async function getExeImports(fileHandle: fs.promises.FileHandle, path: string): Promise<string[]> {
+ try {
+ const ntHeader = await getNtHeader(fileHandle);
+ const fileHeader = ntHeader.get<StructValue<typeof IMAGE_FILE_HEADER>>('FileHeader');
+ const optionalHeader = ntHeader.get<StructValue<ImageOptionalHeaderUnion>>('OptionalHeader');
+
+ const importTableRva = optionalHeader
+ .get<ArrayValue<typeof IMAGE_DATA_DIRECTORY>>('DataDirectory')
+ .nth(IMAGE_DIRECTORY_ENTRY_IMPORT)
+ .get('VirtualAddress')
+ .value();
+
+ if (importTableRva === 0x0) {
+ return [];
+ }
+
+ const numberOfSections = fileHeader.get<PrimitiveValue>('NumberOfSections').value<number>();
+ const ntHeaderEndOffset =
+ ntHeader.offset +
+ ntHeader.get<PrimitiveValue<typeof DWORD>>('Signature').size +
+ fileHeader.size +
+ fileHeader.get<PrimitiveValue>('SizeOfOptionalHeader').value();
+
+ const importTableOffset = await rvaToOffset(
+ fileHandle,
+ importTableRva,
+ numberOfSections,
+ ntHeaderEndOffset,
+ );
+
+ const moduleNames = await getImportModuleNames(
+ fileHandle,
+ importTableOffset,
+ ntHeader.endOffset,
+ numberOfSections,
+ );
+
+ return moduleNames;
+ } catch (e) {
+ log.error(`Failed to read .exe import table for ${path}.`, e);
+ return [];
+ }
+}
+
+async function readString(
+ fileHandle: fs.promises.FileHandle,
+ offset: number,
+ buffer = Buffer.alloc(1000),
+ index = 0,
+): Promise<string> {
+ await fileHandle.read(buffer, index, 1, offset + index);
+ if (buffer[index] === 0x0) {
+ return buffer.slice(0, index).toString('ascii');
+ } else {
+ return readString(fileHandle, offset, buffer, index + 1);
+ }
+}
+
+// Finds and returns the NT header.
+async function getNtHeader(
+ fileHandle: fs.promises.FileHandle,
+): Promise<StructValue<ImageNtHeadersUnion>> {
+ // Check whether or not the file follows the PE format.
+ const dosHeader = await Value.fromFile(fileHandle, 0, DOS_HEADER);
+ const eMagic = dosHeader.get<PrimitiveValue>('e_magic').value<number>();
+ if (eMagic !== 0x5a4d) {
+ throw new Error('Not a PE file');
+ }
+
+ const ntHeaderOffset = dosHeader.get<PrimitiveValue>('e_lfanew').value<number>();
+
+ // Check if this is a 32- or 64-bit exe-file and return the correct datatype.
+ const ntHeader32 = await Value.fromFile(fileHandle, ntHeaderOffset, IMAGE_NT_HEADERS);
+ const signature = ntHeader32.get<PrimitiveValue>('Signature').buffer.toString('ascii');
+ if (signature !== 'PE\0\0') {
+ throw new Error('Not a PE file');
+ }
+
+ const magic = ntHeader32
+ .get<StructValue<typeof IMAGE_OPTIONAL_HEADER32>>('OptionalHeader')
+ .get<PrimitiveValue>('Magic')
+ .value<number>();
+
+ // magic is 0x20b for 64-bit executables.
+ return magic === 0x20b
+ ? Value.fromFile(fileHandle, ntHeaderOffset, IMAGE_NT_HEADERS64)
+ : ntHeader32;
+}
+
+// Reads the import table and returns a list of the imported DLLs.
+async function getImportModuleNames(
+ fileHandle: fs.promises.FileHandle,
+ importTableOffset: number,
+ firstSectionHeaderOffset: number,
+ numberOfSections: number,
+): Promise<string[]> {
+ const moduleNames: string[] = [];
+ const entrySize = Value.sizeOf(IMAGE_IMPORT_MODULE_DIRECTORY);
+
+ // eslint-disable-next-line no-constant-condition
+ for (let i = 0; true; i++) {
+ const importEntry = await Value.fromFile(
+ fileHandle,
+ importTableOffset + i * entrySize,
+ IMAGE_IMPORT_MODULE_DIRECTORY,
+ );
+ const nameRva = importEntry.get('ModuleName').value();
+
+ if (nameRva !== 0x0) {
+ const offset = await rvaToOffset(
+ fileHandle,
+ nameRva,
+ numberOfSections,
+ firstSectionHeaderOffset,
+ );
+
+ const name = await readString(fileHandle, offset);
+ moduleNames.push(name);
+ } else {
+ return moduleNames;
+ }
+ }
+}
diff --git a/gui/src/renderer/redux/settings/reducers.ts b/gui/src/renderer/redux/settings/reducers.ts
index 592caab687..068add8e89 100644
--- a/gui/src/renderer/redux/settings/reducers.ts
+++ b/gui/src/renderer/redux/settings/reducers.ts
@@ -149,6 +149,7 @@ const initialState: ISettingsReduxState = {
monochromaticIcon: false,
startMinimized: false,
unpinnedWindow: window.platform !== 'win32' && window.platform !== 'darwin',
+ browsedForSplitTunnelingApplications: [],
},
relaySettings: {
normal: {
diff --git a/gui/src/shared/gui-settings-state.ts b/gui/src/shared/gui-settings-state.ts
index d8fc1f1ee2..dc5bea66f5 100644
--- a/gui/src/shared/gui-settings-state.ts
+++ b/gui/src/shared/gui-settings-state.ts
@@ -23,4 +23,8 @@ export interface IGuiSettingsState {
// Tells the app wheter or not it should act as a window or a context menu.
unpinnedWindow: boolean;
+
+ // Conains a list of filepaths to applications added to the list of applications, in the split
+ // tunneling view, by the user.
+ browsedForSplitTunnelingApplications: Array<string>;
}