diff --git a/ui/desktop/electron-app/src/cli/index.js b/ui/desktop/electron-app/src/cli/index.js index cfb5153a5..0aaad4e31 100644 --- a/ui/desktop/electron-app/src/cli/index.js +++ b/ui/desktop/electron-app/src/cli/index.js @@ -1,7 +1,7 @@ const path = require('path'); const { spawnSync } = require('../helpers/spawn-promise.js'); -const cliPath = async () => path.resolve(__dirname, '..', 'cli', 'boundary'); +const cliPath = async () => path.resolve(__dirname, '..', '..', 'cli', 'boundary'); module.exports = { // Check boundary cli existence diff --git a/ui/desktop/electron-app/src/config/content-security-policy.js b/ui/desktop/electron-app/src/config/content-security-policy.js index 8895178ef..19ccafc93 100644 --- a/ui/desktop/electron-app/src/config/content-security-policy.js +++ b/ui/desktop/electron-app/src/config/content-security-policy.js @@ -1,4 +1,4 @@ -const origin = require('../origin/index.js'); +const runtimeSettings = require('../services/runtime-settings.js'); const isDev = require('electron-is-dev'); const csp = { @@ -28,9 +28,9 @@ const generateCSPHeader = () => { const policy = Object.assign({}, csp); // If an origin is specified, add it to the connect-src directive - if (origin.origin) { + if (runtimeSettings.origin) { policy['connect-src'] = policy['connect-src'].slice(); - policy['connect-src'].push(origin.origin); + policy['connect-src'].push(runtimeSettings.origin); } return Object.keys(policy) diff --git a/ui/desktop/electron-app/src/helpers/net-request-promise.js b/ui/desktop/electron-app/src/helpers/net-request-promise.js new file mode 100644 index 000000000..c86030c79 --- /dev/null +++ b/ui/desktop/electron-app/src/helpers/net-request-promise.js @@ -0,0 +1,26 @@ +const { net } = require('electron'); + +const requestTimeoutSeconds = 10; +// Simple promise wrapper around Electron's net.request feature. +// https://www.electronjs.org/docs/api/client-request +const netRequestPromise = (url) => + new Promise((resolve, reject) => { + try { + const request = net.request(url); + // We don't worry about canceling the request timeout timer once a + // request ends because: request.abort() has no effect after a request + // ends and the promise will already have been completed, so a rejection + // has no effect either. + const requestTimeout = setTimeout(() => { + request.abort(); + reject(new Error('Request timeout')); + }, requestTimeoutSeconds * 1000); + request.on('error', reject); + request.on('response', resolve); + request.end(); + } catch (e) { + reject(e); + } + }); + +module.exports = netRequestPromise; diff --git a/ui/desktop/electron-app/src/index.js b/ui/desktop/electron-app/src/index.js index 94508b0dd..3b0b79f0d 100644 --- a/ui/desktop/electron-app/src/index.js +++ b/ui/desktop/electron-app/src/index.js @@ -15,7 +15,7 @@ const { } = require('electron'); require('./ipc/handlers.js'); -const origin = require('./origin/index.js'); +const runtimeSettings = require('./services/runtime-settings.js'); const { generateCSPHeader } = require('./config/content-security-policy.js'); const sessionManager = require('./session/manager.js'); @@ -132,7 +132,7 @@ app.on('ready', async () => { // If the user-specified origin changes, reload the page so that // the CSP can be refreshed with the this source allowed - origin.onOriginChange(() => mainWindow.loadURL(emberAppURL)); + runtimeSettings.onOriginChange(() => mainWindow.loadURL(emberAppURL)); // If you want to open up dev tools programmatically, call // mainWindow.openDevTools(); diff --git a/ui/desktop/electron-app/src/ipc/handlers.js b/ui/desktop/electron-app/src/ipc/handlers.js index e727a4b48..529fc34a5 100644 --- a/ui/desktop/electron-app/src/ipc/handlers.js +++ b/ui/desktop/electron-app/src/ipc/handlers.js @@ -2,28 +2,30 @@ const { shell } = require('electron'); const isDev = require('electron-is-dev'); const handle = require('./ipc-handler.js'); const boundaryCli = require('../cli/index.js'); -const origin = require('../origin/index.js'); -const sessionManager = require('../session/manager.js'); +const sessionManager = require('../services/session-manager.js'); +const runtimeSettings = require('../services/runtime-settings.js'); +const sanitizer = require('../utils/sanitizer.js'); /** * Returns the current runtime origin, which is used by the main thread to * rewrite the CSP to allow requests. */ -handle('getOrigin', () => origin.origin); +handle('getOrigin', () => runtimeSettings.origin); /** * Sets the origin to be used in the content security policy and triggers * a main window reload. */ handle('setOrigin', async (requestOrigin) => { - await origin.validateOrigin(requestOrigin); - origin.origin = requestOrigin; + const origin = sanitizer.urlValidate(requestOrigin); + await runtimeSettings.validateOrigin(origin); + runtimeSettings.origin = origin; }); /** * Resets the origin. */ -handle('resetOrigin', async () => origin.resetOrigin()); +handle('resetOrigin', async () => runtimeSettings.resetOrigin()); /** * Opens the specified URL in an external browser. Only secure HTTPs URLs are @@ -55,7 +57,7 @@ handle('cliExists', () => boundaryCli.exists()); * Establishes a boundary session and returns session details. */ handle('connect', ({ target_id, token, host_id }) => - sessionManager.start(origin.origin, target_id, token, host_id) + sessionManager.start(runtimeSettings.origin, target_id, token, host_id) ); /** diff --git a/ui/desktop/electron-app/src/origin/index.js b/ui/desktop/electron-app/src/models/runtime-settings.js similarity index 59% rename from ui/desktop/electron-app/src/origin/index.js rename to ui/desktop/electron-app/src/models/runtime-settings.js index 5cc150c32..fdd91608c 100644 --- a/ui/desktop/electron-app/src/origin/index.js +++ b/ui/desktop/electron-app/src/models/runtime-settings.js @@ -1,34 +1,7 @@ -const { net } = require('electron'); - -const requestTimeoutSeconds = 10; -// Simple promise wrapper around Electron's net.request feature. -// https://www.electronjs.org/docs/api/client-request -const netRequestPromise = (url) => - new Promise((resolve, reject) => { - try { - const request = net.request(url); - // We don't worry about canceling the request timeout timer once a - // request ends because: request.abort() has no effect after a request - // ends and the promise will already have been completed, so a rejection - // has no effect either. - const requestTimeout = setTimeout(() => { - request.abort(); - reject(new Error('Request timeout')); - }, requestTimeoutSeconds * 1000); - request.on('error', reject); - request.on('response', resolve); - request.end(); - } catch (e) { - reject(e); - } - }); - -// Provides a singleton class instance to enable a consistent view of -// runtime settings across the application. +const netRequestPromise = require('../helpers/net-request-promise.js'); // Runtime settings are any configuration, especially user-specified, // set up at runtime. For example, the Boundary origin is a runtime setting. - class RuntimeSettings { // Internal private origin is exposed via getter/setter below. #origin = undefined; @@ -84,6 +57,4 @@ class RuntimeSettings { } } -const runtimeSettings = new RuntimeSettings(); - -module.exports = runtimeSettings; +module.exports = RuntimeSettings; diff --git a/ui/desktop/electron-app/src/models/session.js b/ui/desktop/electron-app/src/models/session.js index 105b7d723..6ec9d3ce8 100644 --- a/ui/desktop/electron-app/src/models/session.js +++ b/ui/desktop/electron-app/src/models/session.js @@ -1,5 +1,5 @@ const { spawnAsyncJSONPromise } = require('../helpers/spawn-promise.js'); -const base62 = require('../utils/base62.js'); +const sanitizer = require('../utils/sanitizer.js'); class Session { #id; @@ -77,9 +77,9 @@ class Session { */ cliCommand() { const sanitized = { - target_id: base62.escapeAndValidate(this.#targetId), - token: base62.escapeAndValidate(this.#token), - addr: encodeURI(this.#addr), + target_id: sanitizer.base62EscapeAndValidate(this.#targetId), + token: sanitizer.base62EscapeAndValidate(this.#token), + addr: sanitizer.urlValidate(this.#addr), }; const command = [ @@ -91,7 +91,7 @@ class Session { ]; if (this.#hostId) { - sanitized.host_id = base62.escapeAndValidate(this.#hostId); + sanitized.host_id = sanitizer.base62EscapeAndValidate(this.#hostId); command.push(`-host-id=${sanitized.host_id}`); } return command; diff --git a/ui/desktop/electron-app/src/services/runtime-settings.js b/ui/desktop/electron-app/src/services/runtime-settings.js new file mode 100644 index 000000000..8c5634079 --- /dev/null +++ b/ui/desktop/electron-app/src/services/runtime-settings.js @@ -0,0 +1,8 @@ +const RuntimeSettings = require('../models/runtime-settings.js'); + +// Provides a singleton class instance to enable a consistent view of +// runtime settings across the application. + +const runtimeSettings = new RuntimeSettings(); + +module.exports = runtimeSettings; diff --git a/ui/desktop/electron-app/src/session/manager.js b/ui/desktop/electron-app/src/services/session-manager.js similarity index 100% rename from ui/desktop/electron-app/src/session/manager.js rename to ui/desktop/electron-app/src/services/session-manager.js diff --git a/ui/desktop/electron-app/src/utils/base62.js b/ui/desktop/electron-app/src/utils/base62.js deleted file mode 100644 index 0c844b790..000000000 --- a/ui/desktop/electron-app/src/utils/base62.js +++ /dev/null @@ -1,14 +0,0 @@ -module.exports = { - /** - * Super paranoid shell quote/escape and validation. Input must be base62. - * @param {string} str - */ - escapeAndValidate: (str) => { - const candidate = str.toString(); - if (candidate.match(/^[A-Za-z0-9_]*$/)) return candidate; - throw new Error(` - Could not invoke command: - input contained unsafe characters. - `); - }, -}; diff --git a/ui/desktop/electron-app/src/utils/sanitizer.js b/ui/desktop/electron-app/src/utils/sanitizer.js new file mode 100644 index 000000000..9e635b0d5 --- /dev/null +++ b/ui/desktop/electron-app/src/utils/sanitizer.js @@ -0,0 +1,30 @@ +const { URL } = require('url'); + +module.exports = { + /** + * Super paranoid shell quote/escape and validation. Input must be base62. + * @param {string} str + */ + base62EscapeAndValidate: (str) => { + const candidate = str.toString(); + if (candidate.match(/^[A-Za-z0-9_]*$/)) return candidate; + throw new Error(` + Could not invoke command: + input contained unsafe characters. + `); + }, + /** + * Return a quoted string suitable for using in shell commands. + * @param {string} str + * @returns {string} + */ + urlValidate: (str) => { + try { + const url = new URL(str); + return `${url.protocol}://${url.host}`; + } catch (e) { + // Parse error + throw new Error(`URL ${str} could not be validated.`); + } + }, +};