From e8b4bcd77a76d7463467a3b0c83f3c4fad921204 Mon Sep 17 00:00:00 2001 From: lutovich Date: Sun, 13 May 2018 23:42:24 +0200 Subject: [PATCH 1/2] Automatically use secure WebSocket on HTTPS pages Previously, driver used in browser used secure WebSocket with 'wss' scheme only when encryption was explicitly turned on in the driver config. On HTTP web pages it is perfectly fine to use either plane or secure WebSocket ('ws' and 'wss' respectively). On HTTPS web pages only 'wss' is allowed. So explicit `{encrypted: true}` configuration was always required for drivers used on HTTPS web pages. This commit makes driver automatically use secure WebSocket when driver is used on a HTTPS web page. --- src/v1/internal/ch-config.js | 7 ++- src/v1/internal/ch-node.js | 5 -- src/v1/internal/ch-websocket.js | 65 +++++++++++++++++++------- src/v1/internal/connector.js | 4 -- test/internal/ch-config.test.js | 6 ++- test/internal/ch-websocket.test.js | 74 +++++++++++++++++++++++++++++- 6 files changed, 131 insertions(+), 30 deletions(-) diff --git a/src/v1/internal/ch-config.js b/src/v1/internal/ch-config.js index 5bb98d1cb..d79b22abb 100644 --- a/src/v1/internal/ch-config.js +++ b/src/v1/internal/ch-config.js @@ -42,9 +42,12 @@ export default class ChannelConfig { function extractEncrypted(driverConfig) { // check if encryption was configured by the user, use explicit null check because we permit boolean value - const encryptionConfigured = driverConfig.encrypted == null; + const encryptionNotConfigured = driverConfig.encrypted == null; // default to using encryption if trust-all-certificates is available - return encryptionConfigured ? hasFeature('trust_all_certificates') : driverConfig.encrypted; + if (encryptionNotConfigured && hasFeature('trust_all_certificates')) { + return true; + } + return driverConfig.encrypted; } function extractTrust(driverConfig) { diff --git a/src/v1/internal/ch-node.js b/src/v1/internal/ch-node.js index a8e07b29d..c88311d11 100644 --- a/src/v1/internal/ch-node.js +++ b/src/v1/internal/ch-node.js @@ -297,7 +297,6 @@ class NodeChannel { this._handleConnectionTerminated = this._handleConnectionTerminated.bind(this); this._connectionErrorCode = config.connectionErrorCode; - this._encrypted = config.encrypted; this._conn = connect(config, () => { if(!self._open) { return; @@ -362,10 +361,6 @@ class NodeChannel { } } - isEncrypted() { - return this._encrypted; - } - /** * Write the passed in buffer to connection * @param {NodeBuffer} buffer - Buffer to write diff --git a/src/v1/internal/ch-websocket.js b/src/v1/internal/ch-websocket.js index b61080341..e415f5c47 100644 --- a/src/v1/internal/ch-websocket.js +++ b/src/v1/internal/ch-websocket.js @@ -29,8 +29,9 @@ class WebSocketChannel { /** * Create new instance * @param {ChannelConfig} config - configuration for this channel. + * @param {function(): string} protocolSupplier - function that detects protocol of the web page. Should only be used in tests. */ - constructor(config) { + constructor(config, protocolSupplier = detectWebPageProtocol) { this._open = true; this._pending = []; @@ -38,18 +39,10 @@ class WebSocketChannel { this._handleConnectionError = this._handleConnectionError.bind(this); this._config = config; - let scheme = "ws"; - //Allow boolean for backwards compatibility - if (config.encrypted === true || config.encrypted === ENCRYPTION_ON) { - if ((!config.trust) || config.trust === 'TRUST_CUSTOM_CA_SIGNED_CERTIFICATES') { - scheme = "wss"; - } else { - this._error = newError("The browser version of this driver only supports one trust " + - 'strategy, \'TRUST_CUSTOM_CA_SIGNED_CERTIFICATES\'. ' + config.trust + ' is not supported. Please ' + - "either use TRUST_CUSTOM_CA_SIGNED_CERTIFICATES or disable encryption by setting " + - "`encrypted:\"" + ENCRYPTION_OFF + "\"` in the driver configuration."); - return; - } + const {scheme, error} = determineWebSocketScheme(config, protocolSupplier); + if (error) { + this._error = error; + return; } this._ws = createWebSocket(scheme, config.url); @@ -114,10 +107,6 @@ class WebSocketChannel { } } - isEncrypted() { - return this._config.encrypted; - } - /** * Write the passed in buffer to connection * @param {HeapBuffer} buffer - Buffer to write @@ -233,4 +222,46 @@ function asWindowsFriendlyIPv6Address(scheme, parsedUrl) { return `${scheme}://${ipv6Host}:${parsedUrl.port}`; } +/** + * @param {ChannelConfig} config - configuration for the channel. + * @param {function(): string} protocolSupplier - function that detects protocol of the web page. + * @return {{scheme: string|null, error: Neo4jError|null}} object containing either scheme or error. + */ +function determineWebSocketScheme(config, protocolSupplier) { + const encrypted = config.encrypted; + const trust = config.trust; + + if (encrypted === false || encrypted === ENCRYPTION_OFF) { + // encryption explicitly turned off in the config + return {scheme: 'ws', error: null}; + } + + const protocol = typeof protocolSupplier === 'function' ? protocolSupplier() : ''; + if (protocol && protocol.toLowerCase().indexOf('https') >= 0) { + // driver is used in a secure https web page, use 'wss' + return {scheme: 'wss', error: null}; + } + + if (encrypted === true || encrypted === ENCRYPTION_ON) { + // encryption explicitly requested in the config + if (!trust || trust === 'TRUST_CUSTOM_CA_SIGNED_CERTIFICATES') { + // trust strategy not specified or the only supported strategy is specified + return {scheme: 'wss', error: null}; + } else { + const error = newError('The browser version of this driver only supports one trust ' + + 'strategy, \'TRUST_CUSTOM_CA_SIGNED_CERTIFICATES\'. ' + trust + ' is not supported. Please ' + + 'either use TRUST_CUSTOM_CA_SIGNED_CERTIFICATES or disable encryption by setting ' + + '`encrypted:"' + ENCRYPTION_OFF + '"` in the driver configuration.'); + return {scheme: null, error: error}; + } + } + + // default to unencrypted web socket + return {scheme: 'ws', error: null}; +} + +function detectWebPageProtocol() { + return window && window.location ? window.location.protocol : null; +} + export default _websocketChannelModule diff --git a/src/v1/internal/connector.js b/src/v1/internal/connector.js index 6898165a3..15e9b44f4 100644 --- a/src/v1/internal/connector.js +++ b/src/v1/internal/connector.js @@ -397,10 +397,6 @@ class Connection { return !this._isBroken && this._ch._open; } - isEncrypted() { - return this._ch.isEncrypted(); - } - /** * Call close on the channel. * @param {function} cb - Function to call on close. diff --git a/test/internal/ch-config.test.js b/test/internal/ch-config.test.js index 48a274858..4543b8afa 100644 --- a/test/internal/ch-config.test.js +++ b/test/internal/ch-config.test.js @@ -76,7 +76,11 @@ describe('ChannelConfig', () => { it('should use encryption if available but not configured', () => { const config = new ChannelConfig(null, {}, ''); - expect(config.encrypted).toEqual(hasFeature('trust_all_certificates')); + if (hasFeature('trust_all_certificates')) { + expect(config.encrypted).toBeTruthy(); + } else { + expect(config.encrypted).toBeFalsy(); + } }); it('should use available trust conf when nothing configured', () => { diff --git a/test/internal/ch-websocket.test.js b/test/internal/ch-websocket.test.js index b544090ca..fdfda3bc6 100644 --- a/test/internal/ch-websocket.test.js +++ b/test/internal/ch-websocket.test.js @@ -19,8 +19,9 @@ import wsChannel from '../../src/v1/internal/ch-websocket'; import ChannelConfig from '../../src/v1/internal/ch-config'; import urlUtil from '../../src/v1/internal/url-util'; -import {SERVICE_UNAVAILABLE} from '../../src/v1/error'; +import {Neo4jError, SERVICE_UNAVAILABLE} from '../../src/v1/error'; import {setTimeoutMock} from './timers-util'; +import {ENCRYPTION_OFF, ENCRYPTION_ON} from '../../src/v1/internal/util'; describe('WebSocketChannel', () => { @@ -94,6 +95,54 @@ describe('WebSocketChannel', () => { } }); + it('should select wss when running on https page', () => { + testWebSocketScheme('https:', {}, 'wss'); + }); + + it('should select ws when running on http page', () => { + testWebSocketScheme('http:', {}, 'ws'); + }); + + it('should select ws when running on https page but encryption turned off with boolean', () => { + testWebSocketScheme('https:', {encrypted: false}, 'ws'); + }); + + it('should select ws when running on https page but encryption turned off with string', () => { + testWebSocketScheme('https:', {encrypted: ENCRYPTION_OFF}, 'ws'); + }); + + it('should select wss when running on http page but encryption configured with boolean', () => { + testWebSocketScheme('http:', {encrypted: true}, 'wss'); + }); + + it('should select wss when running on http page but encryption configured with string', () => { + testWebSocketScheme('http:', {encrypted: ENCRYPTION_ON}, 'wss'); + }); + + it('should fail when encryption configured with unsupported trust strategy', () => { + if (!webSocketChannelAvailable) { + return; + } + + const protocolSupplier = () => 'http:'; + + WebSocket = () => { + return { + close: () => { + } + }; + }; + + const url = urlUtil.parseDatabaseUrl('bolt://localhost:8989'); + const driverConfig = {encrypted: true, trust: 'TRUST_ON_FIRST_USE'}; + const channelConfig = new ChannelConfig(url, driverConfig, SERVICE_UNAVAILABLE); + + const channel = new WebSocketChannel(channelConfig, protocolSupplier); + + expect(channel._error).toBeDefined(); + expect(channel._error.name).toEqual('Neo4jError'); + }); + function testFallbackToLiteralIPv6(boltAddress, expectedWsAddress) { if (!webSocketChannelAvailable) { return; @@ -121,4 +170,27 @@ describe('WebSocketChannel', () => { expect(webSocketChannel._ws.url).toEqual(expectedWsAddress); } + function testWebSocketScheme(windowLocationProtocol, driverConfig, expectedScheme) { + if (!webSocketChannelAvailable) { + return; + } + + const protocolSupplier = () => windowLocationProtocol; + + // replace real WebSocket with a function that memorizes the url + WebSocket = url => { + return { + url: url, + close: () => { + } + }; + }; + + const url = urlUtil.parseDatabaseUrl('bolt://localhost:8989'); + const channelConfig = new ChannelConfig(url, driverConfig, SERVICE_UNAVAILABLE); + const channel = new WebSocketChannel(channelConfig, protocolSupplier); + + expect(channel._ws.url).toEqual(expectedScheme + '://localhost:8989'); + } + }); From c623d8a93962d0633c53c8deb0d9536f918aa883 Mon Sep 17 00:00:00 2001 From: lutovich Date: Thu, 17 May 2018 14:30:54 +0200 Subject: [PATCH 2/2] Warn when driver config inconsistent with web page protocol Encryption can be explicitly turned on or off in the driver config. Driver can be used on both HTTP and HTTPS web pages. Configured encryption has to match the security protocol of the web page. Insecure WebSockets ('ws') have to be used on insecure pages ('http'). Secure WebSockets ('wss') have to be used on secure pages ('https'). WebSockets might not work in a mixed content environment, i.e. 'ws' + 'https' or 'wss' + 'http'. This commit makes driver print a warning when its configuration is inconsistent with the web page. --- src/v1/internal/ch-websocket.js | 51 +++++++++++++++++++++++++++--- test/internal/ch-websocket.test.js | 44 ++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 5 deletions(-) diff --git a/src/v1/internal/ch-websocket.js b/src/v1/internal/ch-websocket.js index e415f5c47..9080d0acd 100644 --- a/src/v1/internal/ch-websocket.js +++ b/src/v1/internal/ch-websocket.js @@ -228,21 +228,23 @@ function asWindowsFriendlyIPv6Address(scheme, parsedUrl) { * @return {{scheme: string|null, error: Neo4jError|null}} object containing either scheme or error. */ function determineWebSocketScheme(config, protocolSupplier) { - const encrypted = config.encrypted; + const encryptionOn = isEncryptionExplicitlyTurnedOn(config); + const encryptionOff = isEncryptionExplicitlyTurnedOff(config); const trust = config.trust; + const secureProtocol = isProtocolSecure(protocolSupplier); + verifyEncryptionSettings(encryptionOn, encryptionOff, secureProtocol); - if (encrypted === false || encrypted === ENCRYPTION_OFF) { + if (encryptionOff) { // encryption explicitly turned off in the config return {scheme: 'ws', error: null}; } - const protocol = typeof protocolSupplier === 'function' ? protocolSupplier() : ''; - if (protocol && protocol.toLowerCase().indexOf('https') >= 0) { + if (secureProtocol) { // driver is used in a secure https web page, use 'wss' return {scheme: 'wss', error: null}; } - if (encrypted === true || encrypted === ENCRYPTION_ON) { + if (encryptionOn) { // encryption explicitly requested in the config if (!trust || trust === 'TRUST_CUSTOM_CA_SIGNED_CERTIFICATES') { // trust strategy not specified or the only supported strategy is specified @@ -260,6 +262,45 @@ function determineWebSocketScheme(config, protocolSupplier) { return {scheme: 'ws', error: null}; } +/** + * @param {ChannelConfig} config - configuration for the channel. + * @return {boolean} true if encryption enabled in the config, false otherwise. + */ +function isEncryptionExplicitlyTurnedOn(config) { + return config.encrypted === true || config.encrypted === ENCRYPTION_ON; +} + +/** + * @param {ChannelConfig} config - configuration for the channel. + * @return {boolean} true if encryption disabled in the config, false otherwise. + */ +function isEncryptionExplicitlyTurnedOff(config) { + return config.encrypted === false || config.encrypted === ENCRYPTION_OFF; +} + +/** + * @param {function(): string} protocolSupplier - function that detects protocol of the web page. + * @return {boolean} true if protocol returned by the given function is secure, false otherwise. + */ +function isProtocolSecure(protocolSupplier) { + const protocol = typeof protocolSupplier === 'function' ? protocolSupplier() : ''; + return protocol && protocol.toLowerCase().indexOf('https') >= 0; +} + +function verifyEncryptionSettings(encryptionOn, encryptionOff, secureProtocol) { + if (encryptionOn && !secureProtocol) { + // encryption explicitly turned on for a driver used on a HTTP web page + console.warn('Neo4j driver is configured to use secure WebSocket on a HTTP web page. ' + + 'WebSockets might not work in a mixed content environment. ' + + 'Please consider configuring driver to not use encryption.'); + } else if (encryptionOff && secureProtocol) { + // encryption explicitly turned off for a driver used on a HTTPS web page + console.warn('Neo4j driver is configured to use insecure WebSocket on a HTTPS web page. ' + + 'WebSockets might not work in a mixed content environment. ' + + 'Please consider configuring driver to use encryption.'); + } +} + function detectWebPageProtocol() { return window && window.location ? window.location.protocol : null; } diff --git a/test/internal/ch-websocket.test.js b/test/internal/ch-websocket.test.js index fdfda3bc6..e5ec48944 100644 --- a/test/internal/ch-websocket.test.js +++ b/test/internal/ch-websocket.test.js @@ -30,11 +30,16 @@ describe('WebSocketChannel', () => { let OriginalWebSocket; let webSocketChannel; + let originalConsoleWarn; beforeEach(() => { if (webSocketChannelAvailable) { OriginalWebSocket = WebSocket; } + originalConsoleWarn = console.warn; + console.warn = () => { + // mute by default + }; }); afterEach(() => { @@ -44,6 +49,7 @@ describe('WebSocketChannel', () => { if (webSocketChannel) { webSocketChannel.close(); } + console.warn = originalConsoleWarn; }); it('should fallback to literal IPv6 when SyntaxError is thrown', () => { @@ -143,6 +149,16 @@ describe('WebSocketChannel', () => { expect(channel._error.name).toEqual('Neo4jError'); }); + it('should generate a warning when encryption turned on for HTTP web page', () => { + testWarningInMixedEnvironment(true, 'http'); + testWarningInMixedEnvironment(ENCRYPTION_ON, 'http'); + }); + + it('should generate a warning when encryption turned off for HTTPS web page', () => { + testWarningInMixedEnvironment(false, 'https'); + testWarningInMixedEnvironment(ENCRYPTION_OFF, 'https'); + }); + function testFallbackToLiteralIPv6(boltAddress, expectedWsAddress) { if (!webSocketChannelAvailable) { return; @@ -193,4 +209,32 @@ describe('WebSocketChannel', () => { expect(channel._ws.url).toEqual(expectedScheme + '://localhost:8989'); } + function testWarningInMixedEnvironment(encrypted, scheme) { + if (!webSocketChannelAvailable) { + return; + } + + // replace real WebSocket with a function that memorizes the url + WebSocket = url => { + return { + url: url, + close: () => { + } + }; + }; + + // replace console.warn with a function that memorizes the message + const warnMessages = []; + console.warn = message => warnMessages.push(message); + + const url = urlUtil.parseDatabaseUrl('bolt://localhost:8989'); + const config = new ChannelConfig(url, {encrypted: encrypted}, SERVICE_UNAVAILABLE); + const protocolSupplier = () => scheme + ':'; + + const channel = new WebSocketChannel(config, protocolSupplier); + + expect(channel).toBeDefined(); + expect(warnMessages.length).toEqual(1); + } + });