diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2021-04-15 15:44:39 +0200 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2021-04-15 15:44:39 +0200 |
| commit | 87bdd6dcf7545a4c31d0b34de2f21f1c3310447c (patch) | |
| tree | 6d66133d6695cc2e741b4f5b6867b888a0776b7d /gui/src | |
| parent | 1cea60f95c613e007cf734ed9be43b2a0b28fde4 (diff) | |
| parent | ba86c078f01fa52d920c32781ac2ee85e9cd1581 (diff) | |
| download | mullvadvpn-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.ts | 42 | ||||
| -rw-r--r-- | gui/src/main/windows-pe-parser.ts | 379 | ||||
| -rw-r--r-- | gui/src/main/windows-split-tunneling.ts | 350 | ||||
| -rw-r--r-- | gui/src/renderer/redux/settings/reducers.ts | 1 | ||||
| -rw-r--r-- | gui/src/shared/gui-settings-state.ts | 4 |
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>; } |
