Skip to content

Commit

Permalink
electron: only allow browser-window
Browse files Browse the repository at this point in the history
Only allow http request from Electron's own browser-window. Token is
generated within electron-main, which also sets it as a cookie within
browser-windows. Token is passed to the backend via environment
variables. The backend is looking for this specific token to authorize
requests.

Fixes https://bugs.eclipse.org/bugs/show_bug.cgi?id=551747

Signed-off-by: Paul Maréchal <paul.marechal@ericsson.com>
  • Loading branch information
paul-marechal committed Feb 25, 2020
1 parent 4d4f6c2 commit 7cc8b32
Show file tree
Hide file tree
Showing 11 changed files with 257 additions and 10 deletions.
Expand Up @@ -99,7 +99,7 @@ function start() {
themeService.loadUserTheme();
const application = container.get(FrontendApplication);
application.start();
return application.start();
}
module.exports = Promise.resolve()${this.compileFrontendModuleImports(frontendModules)}
Expand Down Expand Up @@ -129,10 +129,12 @@ if (process.env.LC_ALL) {
}
process.env.LC_NUMERIC = 'C';
const uuid = require('uuid');
const electron = require('electron');
const { join, resolve } = require('path');
const { fork } = require('child_process');
const { app, dialog, shell, BrowserWindow, ipcMain, Menu, globalShortcut } = electron;
const { ElectronSecurityToken } = require('@theia/core/lib/electron-common/electron-token');
const applicationName = \`${this.pck.props.frontend.config.applicationName}\`;
const isSingleInstance = ${this.pck.props.backend.config.singleInstance === true ? 'true' : 'false'};
Expand All @@ -148,6 +150,10 @@ const nativeKeymap = require('native-keymap');
const Storage = require('electron-store');
const electronStore = new Storage();
const electronSecurityToken = {
value: uuid.v4(),
};
app.on('ready', () => {
if (disallowReloadKeybinding) {
Expand Down Expand Up @@ -300,7 +306,17 @@ app.on('ready', () => {
const loadMainWindow = (port) => {
if (!mainWindow.isDestroyed()) {
mainWindow.loadURL('file://' + join(__dirname, '../../lib/index.html') + '?port=' + port);
mainWindow.webContents.session.cookies.set({
url: \`http://localhost:\${port}/\`,
name: ElectronSecurityToken,
value: JSON.stringify(electronSecurityToken),
}, error => {
if (error) {
console.error(error);
} else {
mainWindow.loadURL('file://' + join(__dirname, '../../lib/index.html') + '?port=' + port);
}
});
}
};
Expand All @@ -322,14 +338,17 @@ app.on('ready', () => {
// We need to distinguish between bundled application and development mode when starting the clusters.
// See: https://github.com/electron/electron/issues/6337#issuecomment-230183287
if (devMode) {
process.env[ElectronSecurityToken] = JSON.stringify(electronSecurityToken);
require(mainPath).then(address => {
loadMainWindow(address.port);
}).catch((error) => {
console.error(error);
app.exit(1);
});
} else {
const cp = fork(mainPath, [], { env: Object.assign({}, process.env) });
const cp = fork(mainPath, [], { env: Object.assign({
[ElectronSecurityToken]: JSON.stringify(electronSecurityToken),
}, process.env) });
cp.on('message', (address) => {
loadMainWindow(address.port);
});
Expand Down
5 changes: 5 additions & 0 deletions packages/core/package.json
Expand Up @@ -10,6 +10,7 @@
"@primer/octicons-react": "^9.0.0",
"@theia/application-package": "^0.15.0",
"@types/body-parser": "^1.16.4",
"@types/cookie": "^0.3.3",
"@types/express": "^4.16.0",
"@types/fs-extra": "^4.0.2",
"@types/lodash.debounce": "4.0.3",
Expand All @@ -22,6 +23,7 @@
"@types/yargs": "^11.1.0",
"ajv": "^6.5.3",
"body-parser": "^1.17.2",
"cookie": "^0.4.0",
"es6-promise": "^4.2.4",
"express": "^4.16.3",
"file-icons-js": "^1.0.3",
Expand Down Expand Up @@ -61,6 +63,9 @@
"frontend": "lib/browser/keyboard/browser-keyboard-module",
"frontendElectron": "lib/electron-browser/keyboard/electron-keyboard-module",
"backendElectron": "lib/electron-node/keyboard/electron-backend-keyboard-module"
},
{
"backendElectron": "lib/electron-node/token/electron-token-backend-module"
}
],
"keywords": [
Expand Down
26 changes: 26 additions & 0 deletions packages/core/src/electron-common/electron-token.ts
@@ -0,0 +1,26 @@
/********************************************************************************
* Copyright (C) 2020 Ericsson and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/

/**
* This token is unique the the current running instance. It is used by the backend
* to make sure it is an electron browser window that is connecting to its services.
*
* The identifier is a string, which makes it usable as a key for cookies or similar.
*/
export const ElectronSecurityToken = 'x-theia-electron-token';
export interface ElectronSecurityToken {
value: string;
};
@@ -0,0 +1,48 @@
/********************************************************************************
* Copyright (C) 2020 Ericsson and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/

import express = require('express');
import { injectable, inject } from 'inversify';
import { BackendApplicationContribution } from '../../node';
import { ElectronTokenValidator } from './electron-token-validator';

/**
* This component contributes an Express middleware that will refuse all
* requests that do not include a specific token.
*/
@injectable()
export class ElectronTokenBackendContribution implements BackendApplicationContribution {

@inject(ElectronTokenValidator)
protected readonly tokenValidator: ElectronTokenValidator;

configure(app: express.Application): void {
app.use(this.expressMiddleware.bind(this));
}

/**
* Only allow token-bearers.
*/
protected expressMiddleware(req: express.Request, res: express.Response, next: express.NextFunction): void {
if (this.tokenValidator.allowRequest(req)) {
next();
} else {
console.error(`refused an http request: ${req.connection.remoteAddress}`);
res.sendStatus(403);
}
}

}
@@ -0,0 +1,31 @@
/********************************************************************************
* Copyright (C) 2020 Ericsson and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/

import { ContainerModule } from 'inversify';
import { BackendApplicationContribution, MessagingService } from '../../node';
import { MessagingContribution } from '../../node/messaging/messaging-contribution';
import { ElectronTokenBackendContribution } from './electron-token-backend-contribution';
import { ElectronMessagingContribution } from './electron-token-messaging-contribution';
import { ElectronTokenValidator } from './electron-token-validator';

export default new ContainerModule((bind, unbind, isBound, rebind) => {
bind<ElectronTokenValidator>(ElectronTokenValidator).toSelf().inSingletonScope();

bind<ElectronTokenBackendContribution>(ElectronTokenBackendContribution).toSelf().inSingletonScope();
bind<BackendApplicationContribution>(BackendApplicationContribution).toService(ElectronTokenBackendContribution);

rebind<MessagingContribution>(MessagingService.Identifier).to(ElectronMessagingContribution).inSingletonScope();
});
@@ -0,0 +1,44 @@
/********************************************************************************
* Copyright (C) 2020 Ericsson and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/

import * as net from 'net';
import * as http from 'http';
import { injectable, inject } from 'inversify';
import { MessagingContribution } from '../../node/messaging/messaging-contribution';
import { ElectronTokenValidator } from './electron-token-validator';

/**
* Override the browser MessagingContribution class to refuse connections that do not include a specific token.
*/
@injectable()
export class ElectronMessagingContribution extends MessagingContribution {

@inject(ElectronTokenValidator)
protected readonly tokenValidator: ElectronTokenValidator;

/**
* Only allow token-bearers.
*/
protected handleHttpUpgrade(request: http.IncomingMessage, socket: net.Socket, head: Buffer): void {
if (this.tokenValidator.allowRequest(request)) {
super.handleHttpUpgrade(request, socket, head);
} else {
console.error(`refused a websocket connection: ${request.connection.remoteAddress}`);
socket.destroy(); // kill connection, client will take that as a "no".
}
}

}
55 changes: 55 additions & 0 deletions packages/core/src/electron-node/token/electron-token-validator.ts
@@ -0,0 +1,55 @@
/********************************************************************************
* Copyright (C) 2020 Ericsson and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/

import * as http from 'http';
import * as cookie from 'cookie';
import { injectable } from 'inversify';
import { ElectronSecurityToken } from '../../electron-common/electron-token';

/**
* On Electron, we want to make sure that only Electron's browser-windows access the backend services.
*/
@injectable()
export class ElectronTokenValidator {

protected electronSecurityToken: ElectronSecurityToken = this.getToken();

/**
* Expects the token to be passed via cookies by default.
*/
allowRequest(request: http.IncomingMessage): boolean {
const cookieHeader = request.headers.cookie;
if (typeof cookieHeader === 'string') {
const token = cookie.parse(cookieHeader)[ElectronSecurityToken];
if (typeof token === 'string') {
return this.isTokenValid(JSON.parse(token));
}
}
return false;
}

isTokenValid(token: ElectronSecurityToken): boolean {
return typeof token === 'object' && token.value === this.electronSecurityToken!.value;
}

/**
* Returns the token to compare to when authorizing requests.
*/
protected getToken(): ElectronSecurityToken {
return JSON.parse(process.env[ElectronSecurityToken]!);
}

}
4 changes: 2 additions & 2 deletions packages/core/src/node/messaging/messaging-backend-module.ts
Expand Up @@ -24,11 +24,11 @@ import { MessagingService } from './messaging-service';
export const messagingBackendModule = new ContainerModule(bind => {
bindContributionProvider(bind, ConnectionContainerModule);
bindContributionProvider(bind, MessagingService.Contribution);
bind(MessagingService.Identifier).to(MessagingContribution).inSingletonScope();
bind(MessagingContribution).toDynamicValue(({ container }) => {
const child = container.createChild();
child.bind(MessagingContainer).toConstantValue(container);
child.bind(MessagingContribution).toSelf();
return child.get(MessagingContribution);
return child.get(MessagingService.Identifier);
}).inSingletonScope();
bind(BackendApplicationContribution).toService(MessagingContribution);
});
20 changes: 16 additions & 4 deletions packages/core/src/node/messaging/messaging-contribution.ts
Expand Up @@ -16,6 +16,7 @@

import * as ws from 'ws';
import * as url from 'url';
import * as net from 'net';
import * as http from 'http';
import * as https from 'https';
import { injectable, inject, named, postConstruct, interfaces } from 'inversify';
Expand Down Expand Up @@ -46,6 +47,7 @@ export class MessagingContribution implements BackendApplicationContribution, Me
@inject(ContributionProvider) @named(MessagingService.Contribution)
protected readonly contributions: ContributionProvider<MessagingService.Contribution>;

protected webSocketServer: ws.Server | undefined;
protected readonly wsHandlers = new MessagingContribution.ConnectionHandlers<ws>();
protected readonly channelHandlers = new MessagingContribution.ConnectionHandlers<WebSocketChannel>();

Expand Down Expand Up @@ -81,23 +83,24 @@ export class MessagingContribution implements BackendApplicationContribution, Me

protected checkAliveTimeout = 30000;
onStart(server: http.Server | https.Server): void {
const wss = new ws.Server({
server,
this.webSocketServer = new ws.Server({
noServer: true,
perMessageDeflate: {
// don't compress if a message is less than 256kb
threshold: 256 * 1024
}
});
server.on('upgrade', this.handleHttpUpgrade.bind(this));
interface CheckAliveWS extends ws {
alive: boolean;
}
wss.on('connection', (socket: CheckAliveWS, request) => {
this.webSocketServer.on('connection', (socket: CheckAliveWS, request) => {
socket.alive = true;
socket.on('pong', () => socket.alive = true);
this.handleConnection(socket, request);
});
setInterval(() => {
wss.clients.forEach((socket: CheckAliveWS) => {
this.webSocketServer!.clients.forEach((socket: CheckAliveWS) => {
if (socket.alive === false) {
socket.terminate();
return;
Expand All @@ -108,6 +111,15 @@ export class MessagingContribution implements BackendApplicationContribution, Me
}, this.checkAliveTimeout);
}

/**
* Route HTTP upgrade requests to the WebSocket server.
*/
protected handleHttpUpgrade(request: http.IncomingMessage, socket: net.Socket, head: Buffer): void {
this.webSocketServer!.handleUpgrade(request, socket, head, client => {
this.webSocketServer!.emit('connection', client, request);
});
}

protected handleConnection(socket: ws, request: http.IncomingMessage): void {
const pathname = request.url && url.parse(request.url).pathname;
if (pathname && !this.wsHandlers.route(pathname, socket)) {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/node/messaging/messaging-service.ts
Expand Up @@ -46,6 +46,8 @@ export interface MessagingService {
ws(path: string, callback: (params: MessagingService.PathParams, socket: ws) => void): void;
}
export namespace MessagingService {
/** Inversify container identifier for the `MessagingService` component. */
export const Identifier = Symbol('MessagingService');
export interface PathParams {
[name: string]: string
}
Expand Down

0 comments on commit 7cc8b32

Please sign in to comment.