Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 59 additions & 1 deletion src/updates/main.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { execFile } from 'child_process';
import fs from 'fs';
import path from 'path';
import { promisify } from 'util';

import { app } from 'electron';
import { autoUpdater } from 'electron-updater';
Expand Down Expand Up @@ -640,7 +642,63 @@ export const setupUpdates = async (): Promise<void> => {
});
});

autoUpdater.addListener('update-downloaded', async () => {
autoUpdater.addListener('update-downloaded', async (info: unknown) => {
const downloadedFile = (info as { downloadedFile?: string } | undefined)
?.downloadedFile;

if (process.platform === 'win32') {
if (!downloadedFile) {
console.error(
'Update integrity check failed: downloadedFile is missing'
);
dispatchUpdateError(
new Error(
'Update signature verification failed. The update has been discarded for your safety.'
)
);
return;
}

try {
const execFileAsync = promisify(execFile);

const { stdout } = await execFileAsync(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-Command',
'& { $filePath = if ($env:ROCKETCHAT_UPDATE_FILE) { $env:ROCKETCHAT_UPDATE_FILE } else { $args[0] }; Get-AuthenticodeSignature -LiteralPath $filePath | Select-Object -ExpandProperty Status }',
downloadedFile,
],
{
env: {
...process.env,
ROCKETCHAT_UPDATE_FILE: downloadedFile,
},
}
);
if (stdout.trim() !== 'Valid') {
throw new Error(
`Signature verification failed on Windows: ${stdout}`
);
}
} catch (err: unknown) {
console.error('Update integrity check failed, discarding update:', err);
try {
fs.unlinkSync(downloadedFile);
} catch {
// ignore cleanup errors
}
dispatchUpdateError(
new Error(
'Update signature verification failed. The update has been discarded for your safety.'
)
);
return;
}
}

dispatch({ type: UPDATES_UPDATE_DOWNLOADED });

// Downloads started from the titlebar label surface the restart action in
Expand Down
126 changes: 124 additions & 2 deletions src/updates/main/setupUpdates.main.spec.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,39 @@
import fs from 'fs';
import util from 'util';

import { ABOUT_DIALOG_UPDATE_CHANNEL_CHANGED } from '../../ui/actions';
// eslint-disable-next-line import/order
import {
UPDATE_SKIPPED,
UPDATES_CHECK_FOR_UPDATES_REQUESTED,
UPDATES_ERROR_THROWN,
UPDATES_INSTALL_REQUESTED,
UPDATES_SKIP_REQUESTED,
UPDATES_UPDATE_DOWNLOADED,
} from '../actions';

const listeners = new Map<string, Function>();
const autoUpdaterListeners = new Map<string, (...args: unknown[]) => unknown>();
const select = jest.fn();
const dispatch = jest.fn();
const mockExecFileAsync = jest.fn();
const mockExecFile = Object.assign(
jest.fn((...args: unknown[]) => {
const callback = args[args.length - 1];
if (typeof callback === 'function') {
mockExecFileAsync(...args.slice(0, -1))
.then((res: unknown) =>
(callback as (...cbArgs: unknown[]) => void)(null, res)
)
.catch((err: unknown) =>
(callback as (...cbArgs: unknown[]) => void)(err)
);
}
}),
{ [util.promisify.custom]: mockExecFileAsync }
);
const mockUnlinkSync = jest.fn();

// Mirrors electron-updater: the constructor allows prereleases when the
// running version has a prerelease id, and the `channel` setter always
// turns `allowDowngrade` on.
Expand All @@ -32,25 +54,36 @@ const autoUpdater = {
checkForUpdatesAndNotify: jest.fn(async () => undefined),
quitAndInstall: jest.fn(),
downloadUpdate: jest.fn(async () => undefined),
on: jest.fn(),
on: jest.fn((event: string, fn: (...args: unknown[]) => unknown) => {
autoUpdaterListeners.set(event, fn);
}),
once: jest.fn(),
addListener: jest.fn(),
addListener: jest.fn((event: string, fn: (...args: unknown[]) => unknown) => {
autoUpdaterListeners.set(event, fn);
}),
removeListener: jest.fn(),
removeAllListeners: jest.fn(),
updateConfigPath: '',
};

jest.mock('child_process', () => ({
execFile: (...args: unknown[]) => mockExecFile(...args),
}));

jest.mock('fs', () => ({
promises: {
readFile: jest.fn(async () => '{}'),
},
unlinkSync: (...args: unknown[]) => mockUnlinkSync(...args),
}));

jest.mock('electron', () => ({
app: {
getAppPath: jest.fn(() => '/app'),
getPath: jest.fn(() => '/userData'),
isPackaged: true,
listeners: jest.fn(() => []),
removeAllListeners: jest.fn(),
},
BrowserWindow: {
getAllWindows: jest.fn(() => []),
Expand Down Expand Up @@ -99,6 +132,7 @@ describe('updates/setupUpdates', () => {
beforeEach(() => {
jest.clearAllMocks();
listeners.clear();
autoUpdaterListeners.clear();
Object.defineProperty(process, 'platform', {
value: 'win32',
configurable: true,
Expand Down Expand Up @@ -234,4 +268,92 @@ describe('updates/setupUpdates', () => {
expect(autoUpdater.allowDowngrade).toBe(false);
});
});

describe('update signature verification', () => {
it('verifies valid Windows signature and dispatches UPDATES_UPDATE_DOWNLOADED', async () => {
mockExecFileAsync.mockResolvedValueOnce({ stdout: 'Valid\r\n' });
await setupUpdates();

const listener = autoUpdaterListeners.get('update-downloaded');
expect(listener).toBeDefined();

await listener?.({ downloadedFile: 'C:\\path\\to\\installer.exe' });

expect(mockExecFileAsync).toHaveBeenCalledWith(
'powershell.exe',
expect.arrayContaining(['-NoProfile', '-NonInteractive', '-Command']),
expect.objectContaining({
env: expect.objectContaining({
ROCKETCHAT_UPDATE_FILE: 'C:\\path\\to\\installer.exe',
}),
})
);
expect(dispatch).toHaveBeenCalledWith({
type: UPDATES_UPDATE_DOWNLOADED,
});
expect(mockUnlinkSync).not.toHaveBeenCalled();
});

it('discards unverified installer, dispatches error, and halts install', async () => {
mockExecFileAsync.mockResolvedValueOnce({ stdout: 'HashMismatch\r\n' });
await setupUpdates();

const listener = autoUpdaterListeners.get('update-downloaded');
expect(listener).toBeDefined();

await listener?.({ downloadedFile: 'C:\\path\\to\\tampered.exe' });

expect(mockUnlinkSync).toHaveBeenCalledWith('C:\\path\\to\\tampered.exe');
expect(dispatch).toHaveBeenCalledWith({
type: UPDATES_ERROR_THROWN,
payload: expect.objectContaining({
message:
'Update signature verification failed. The update has been discarded for your safety.',
}),
});
expect(dispatch).not.toHaveBeenCalledWith({
type: UPDATES_UPDATE_DOWNLOADED,
});
});

it('fails closed when downloadedFile is missing on Windows', async () => {
await setupUpdates();

const listener = autoUpdaterListeners.get('update-downloaded');
expect(listener).toBeDefined();

await listener?.({});

expect(mockExecFileAsync).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenCalledWith({
type: UPDATES_ERROR_THROWN,
payload: expect.objectContaining({
message:
'Update signature verification failed. The update has been discarded for your safety.',
}),
});
expect(dispatch).not.toHaveBeenCalledWith({
type: UPDATES_UPDATE_DOWNLOADED,
});
});

it('skips signature verification on non-Windows platforms', async () => {
Object.defineProperty(process, 'platform', {
value: 'darwin',
configurable: true,
});

await setupUpdates();

const listener = autoUpdaterListeners.get('update-downloaded');
expect(listener).toBeDefined();

await listener?.({ downloadedFile: '/path/to/update.pkg' });

expect(mockExecFileAsync).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenCalledWith({
type: UPDATES_UPDATE_DOWNLOADED,
});
});
});
Comment thread
jhaabhijeet864 marked this conversation as resolved.
});
Loading