From caaa7b159b52a782612c1c0aff0244a048cec9a4 Mon Sep 17 00:00:00 2001 From: LavrovArtem Date: Wed, 15 Nov 2017 17:21:58 +0300 Subject: [PATCH] fix `Override WebSocket class` (close #911) (#1341) * fix `Override WebSocket class` (close #911) * fix origin header * add server tests * fix tests * fix review's issues * fix tests * fix tests * fix review issue * add tests and override origin property * add server tests * add one more client test --- package.json | 3 +- src/client/sandbox/native-methods.js | 33 +++- src/client/sandbox/node/window.js | 103 +++++++++- src/client/utils/dom.js | 4 + src/client/utils/url.js | 36 +++- src/proxy/index.js | 10 + src/request-pipeline/context.js | 8 +- .../destination-request/index.js | 8 + src/request-pipeline/index.js | 8 +- src/request-pipeline/websocket.js | 32 +++ src/utils/url.js | 57 ++++-- .../fixtures/sandbox/node/classes-test.js | 129 ++++++++++++- test/client/fixtures/utils/dom-test.js | 12 ++ test/server/proxy-test.js | 182 ++++++++++++++++++ 14 files changed, 585 insertions(+), 40 deletions(-) create mode 100644 src/request-pipeline/websocket.js diff --git a/package.json b/package.json index ab6af1ae4..40caf4bb0 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,8 @@ "request": "~2.27.0", "self-signed-https": "^1.0.5", "tmp": "0.0.26", - "webmake": "0.3.42" + "webmake": "0.3.42", + "ws": "3.2.0" }, "main": "lib/index", "engines": { diff --git a/src/client/sandbox/native-methods.js b/src/client/sandbox/native-methods.js index cb51398c9..4b363a617 100644 --- a/src/client/sandbox/native-methods.js +++ b/src/client/sandbox/native-methods.js @@ -173,14 +173,15 @@ class NativeMethods { this.dateNow = win.Date.now; // Object - this.objectToString = win.Object.prototype.toString; - this.objectAssign = win.Object.assign; - this.objectKeys = win.Object.keys; - this.objectDefineProperty = win.Object.defineProperty; - this.objectDefineProperties = win.Object.defineProperties; - this.objectCreate = win.Object.create; - this.objectIsExtensible = win.Object.isExtensible; - this.objectIsFrozen = win.Object.isFrozen; + this.objectToString = win.Object.prototype.toString; + this.objectAssign = win.Object.assign; + this.objectKeys = win.Object.keys; + this.objectDefineProperty = win.Object.defineProperty; + this.objectDefineProperties = win.Object.defineProperties; + this.objectCreate = win.Object.create; + this.objectIsExtensible = win.Object.isExtensible; + this.objectIsFrozen = win.Object.isFrozen; + this.objectGetOwnPropertyDescriptor = win.Object.getOwnPropertyDescriptor; // DOMParser if (win.DOMParser) @@ -196,6 +197,21 @@ class NativeMethods { if (textAreaValueDescriptor && typeof textAreaValueDescriptor.set === 'function') this.textAreaValueSetter = textAreaValueDescriptor.set; + // Getters + if (win.WebSocket) { + const urlPropDescriptor = win.Object.getOwnPropertyDescriptor(window.WebSocket.prototype, 'url'); + + if (urlPropDescriptor && urlPropDescriptor.get && urlPropDescriptor.configurable) + this.webSocketUrlGetter = urlPropDescriptor.get; + } + + if (win.MessageEvent) { + const originPropDescriptor = win.Object.getOwnPropertyDescriptor(window.MessageEvent.prototype, 'origin'); + + if (originPropDescriptor && originPropDescriptor.get && originPropDescriptor.configurable) + this.messageEventOriginGetter = originPropDescriptor.get; + } + // Stylesheets if (win.CSSStyleDeclaration) { this.CSSStyleDeclarationGetPropertyValue = win.CSSStyleDeclaration.prototype.getPropertyValue; @@ -248,6 +264,7 @@ class NativeMethods { this.StorageEvent = win.StorageEvent; this.MutationObserver = win.MutationObserver; this.EventSource = win.EventSource; + this.WebSocket = win.WebSocket; if (win.DataTransfer) this.DataTransfer = win.DataTransfer; diff --git a/src/client/sandbox/node/window.js b/src/client/sandbox/node/window.js index cfd1c3715..80d5dfb84 100644 --- a/src/client/sandbox/node/window.js +++ b/src/client/sandbox/node/window.js @@ -6,9 +6,16 @@ import { processScript } from '../../../processing/script'; import styleProcessor from '../../../processing/style'; import * as destLocation from '../../utils/destination-location'; import { processHtml } from '../../utils/html'; -import { isSubDomain, parseUrl, getProxyUrl, parseProxyUrl, convertToProxyUrl, stringifyResourceType } from '../../utils/url'; +import { + isSubDomain, + parseUrl, + getProxyUrl, + parseProxyUrl, + convertToProxyUrl, + stringifyResourceType +} from '../../utils/url'; import { isFirefox, isIE9, isIE } from '../../utils/browser'; -import { isCrossDomainWindows, isImgElement, isBlob } from '../../utils/dom'; +import { isCrossDomainWindows, isImgElement, isBlob, isWebSocket } from '../../utils/dom'; import { isPrimitiveType } from '../../utils/types'; import INTERNAL_ATTRS from '../../../processing/dom/internal-attributes'; import constructorIsCalledWithoutNewKeyword from '../../utils/constructor-is-called-without-new-keyword'; @@ -20,6 +27,8 @@ const nativeFunctionToString = nativeMethods.Function.toString(); // since they can be overriden by the client code. (GH-245) const arrayConcat = Array.prototype.concat; +const HTTP_PROTOCOL_RE = /^http/i; + export default class WindowSandbox extends SandboxBase { constructor (nodeSandbox, messageSandbox, listenersSandbox) { super(); @@ -224,8 +233,9 @@ export default class WindowSandbox extends SandboxBase { if (typeof scriptURL === 'string') scriptURL = getProxyUrl(scriptURL, { resourceType: stringifyResourceType({ isScript: true }) }); - return arguments.length === - 1 ? new nativeMethods.Worker(scriptURL) : new nativeMethods.Worker(scriptURL, options); + return arguments.length === 1 + ? new nativeMethods.Worker(scriptURL) + : new nativeMethods.Worker(scriptURL, options); }; window.Worker.prototype = nativeMethods.Worker.prototype; } @@ -420,6 +430,91 @@ export default class WindowSandbox extends SandboxBase { }; } + if (window.WebSocket) { + window.WebSocket = function (url, protocols) { + if (arguments.length === 0) + return new nativeMethods.WebSocket(); + + const proxyUrl = getProxyUrl(url, { resourceType: stringifyResourceType({ isWebSocket: true }) }); + const parsedUrl = parseUrl(url); + const origin = parsedUrl.protocol + '//' + parsedUrl.host; + let webSocket = null; + + if (arguments.length === 1) + webSocket = new nativeMethods.WebSocket(proxyUrl); + else if (arguments.length === 2) + webSocket = new nativeMethods.WebSocket(proxyUrl, protocols); + else + webSocket = new nativeMethods.WebSocket(proxyUrl, protocols, arguments[2]); + + // NOTE: We need to use deprecated methods instead of the defineProperty method + // in the following browsers: + // * Android 5.1 because prototype does not contain the url property + // and the defineProperty does not redefine descriptor of the WebSocket instance + // * Safari less than 10 versions because the configurable property of descriptor is false + // Try to drop this after moving to higher versions of browsers + if (!nativeMethods.webSocketUrlGetter) { + webSocket.__defineGetter__('url', () => url); + webSocket.__defineSetter__('url', value => value); + } + + if (!nativeMethods.messageEventOriginGetter) { + webSocket.addEventListener('message', e => { + e.__defineGetter__('origin', () => origin); + e.__defineSetter__('origin', value => value); + }); + } + + return webSocket; + }; + + window.WebSocket.prototype = nativeMethods.WebSocket.prototype; + window.WebSocket.CONNECTING = nativeMethods.WebSocket.CONNECTING; + window.WebSocket.OPEN = nativeMethods.WebSocket.OPEN; + window.WebSocket.CLOSING = nativeMethods.WebSocket.CLOSING; + window.WebSocket.CLOSED = nativeMethods.WebSocket.CLOSED; + + if (nativeMethods.webSocketUrlGetter) { + const urlPropDescriptor = nativeMethods.objectGetOwnPropertyDescriptor + .call(window.Object, window.WebSocket.prototype, 'url'); + + urlPropDescriptor.get = function () { + const url = nativeMethods.webSocketUrlGetter.call(this); + const parsedUrl = parseProxyUrl(url); + + if (parsedUrl && parsedUrl.destUrl) + return parsedUrl.destUrl.replace(HTTP_PROTOCOL_RE, 'ws'); + + return url; + }; + + nativeMethods.objectDefineProperty + .call(window.Object, window.WebSocket.prototype, 'url', urlPropDescriptor); + } + } + + if (nativeMethods.messageEventOriginGetter) { + const originPropDescriptor = nativeMethods.objectGetOwnPropertyDescriptor + .call(window.Object, window.MessageEvent.prototype, 'origin'); + + originPropDescriptor.get = function () { + const target = this.target; + const origin = nativeMethods.messageEventOriginGetter.call(this); + + if (isWebSocket(target)) { + const parsedUrl = parseUrl(target.url); + + if (parsedUrl) + return parsedUrl.protocol + '//' + parsedUrl.host; + } + + return origin; + }; + + nativeMethods.objectDefineProperty + .call(window.Object, window.MessageEvent.prototype, 'origin', originPropDescriptor); + } + // NOTE: DOMParser supports an HTML parsing for IE10 and later if (window.DOMParser && !isIE9) { window.DOMParser.prototype.parseFromString = function (...args) { diff --git a/src/client/utils/dom.js b/src/client/utils/dom.js index 40d4b6577..190146fd2 100644 --- a/src/client/utils/dom.js +++ b/src/client/utils/dom.js @@ -666,6 +666,10 @@ export function isTableDataCellElement (el) { return instanceToString(el) === NATIVE_TABLE_CELL_STR; } +export function isWebSocket (ws) { + return instanceToString(ws) === '[object WebSocket]'; +} + export function matches (el, selector) { if (!el) return false; diff --git a/src/client/utils/url.js b/src/client/utils/url.js index c56f009a6..fb9241b28 100644 --- a/src/client/utils/url.js +++ b/src/client/utils/url.js @@ -4,25 +4,30 @@ import * as destLocation from './destination-location'; import * as urlResolver from './url-resolver'; import settings from '../settings'; -const HASH_RE = /#[\S\s]*$/; +const HASH_RE = /#[\S\s]*$/; +const SUPPORTED_WEB_SOCKET_PROTOCOL_RE = /^wss?:/i; export const REQUEST_DESCRIPTOR_VALUES_SEPARATOR = sharedUrlUtils.REQUEST_DESCRIPTOR_VALUES_SEPARATOR; export function getProxyUrl (url, opts) { - if (!isSupportedProtocol(url) && !isSpecialPage(url)) + const resourceType = opts && opts.resourceType; + const parsedResourceType = sharedUrlUtils.parseResourceType(resourceType); + + if (!parsedResourceType.isWebSocket && !isSupportedProtocol(url) && !isSpecialPage(url)) return url; // NOTE: Resolves relative URLs. url = destLocation.resolveUrl(url); - if (!sharedUrlUtils.isValidUrl(url)) + if (parsedResourceType.isWebSocket && !isValidWebSocketUrl(url) || !sharedUrlUtils.isValidUrl(url)) return url; + const proxyProtocol = parsedResourceType.isWebSocket ? 'ws:' : void 0; const proxyHostname = opts && opts.proxyHostname || location.hostname; const proxyPort = opts && opts.proxyPort || location.port.toString(); const sessionId = opts && opts.sessionId || settings.get().sessionId; - const resourceType = opts && opts.resourceType; let charset = opts && opts.charset; + let reqOrigin = opts && opts.reqOrigin; const crossDomainPort = getCrossDomainProxyPort(proxyPort); @@ -41,18 +46,19 @@ export function getProxyUrl (url, opts) { const destUrl = sharedUrlUtils.formatUrl(parsedProxyUrl.destResourceInfo); return getProxyUrl(destUrl, { + proxyProtocol, proxyHostname, proxyPort, sessionId, resourceType, - charset + charset, + reqOrigin }); } const parsedUrl = sharedUrlUtils.parseUrl(url); - const isScript = sharedUrlUtils.parseResourceType(resourceType).isScript; - charset = charset || isScript && document[INTERNAL_PROPS.documentCharset]; + charset = charset || parsedResourceType.isScript && document[INTERNAL_PROPS.documentCharset]; // NOTE: It seems that the relative URL had the leading slash or dots, so that the proxy info path part was // removed by the resolver and we have an origin URL with the incorrect host and protocol. @@ -67,12 +73,20 @@ export function getProxyUrl (url, opts) { url = sharedUrlUtils.formatUrl(parsedUrl); } + if (parsedResourceType.isWebSocket) { + parsedUrl.protocol = parsedUrl.protocol.replace('ws', 'http'); + url = sharedUrlUtils.formatUrl(parsedUrl); + reqOrigin = reqOrigin || encodeURIComponent(destLocation.getOriginHeader()); + } + return sharedUrlUtils.getProxyUrl(url, { + proxyProtocol, proxyHostname, proxyPort, sessionId, resourceType, - charset + charset, + reqOrigin }); } @@ -133,6 +147,12 @@ export function changeDestUrlPart (proxyUrl, prop, value, resourceType) { return proxyUrl; } +export function isValidWebSocketUrl (url) { + const resolvedUrl = resolveUrlAsDest(url); + + return SUPPORTED_WEB_SOCKET_PROTOCOL_RE.test(resolvedUrl); +} + export function isSubDomain (domain, subDomain) { return sharedUrlUtils.isSubDomain(domain, subDomain); } diff --git a/src/proxy/index.js b/src/proxy/index.js index 67be1adf3..8ca6793ff 100644 --- a/src/proxy/index.js +++ b/src/proxy/index.js @@ -44,6 +44,9 @@ export default class Proxy extends Router { this.server1 = http.createServer((req, res) => this._onRequest(req, res, this.server1Info)); this.server2 = http.createServer((req, res) => this._onRequest(req, res, this.server2Info)); + this.server1.on('upgrade', (req, socket, head) => this._onUpgradeRequest(req, socket, head, this.server1Info)); + this.server2.on('upgrade', (req, socket, head) => this._onUpgradeRequest(req, socket, head, this.server2Info)); + this.server1.listen(port1); this.server2.listen(port2); @@ -139,6 +142,13 @@ export default class Proxy extends Router { runRequestPipeline(req, res, serverInfo, this.openSessions); } + _onUpgradeRequest (req, socket, head, serverInfo) { + if (head && head.length) + socket.unshift(head); + + this._onRequest(req, socket, serverInfo); + } + _processStaticContent (handler) { if (handler.isShadowUIStylesheet) handler.content = prepareShadowUIStylesheet(handler.content); diff --git a/src/request-pipeline/context.js b/src/request-pipeline/context.js index db443e433..f2e2ef119 100644 --- a/src/request-pipeline/context.js +++ b/src/request-pipeline/context.js @@ -25,6 +25,7 @@ export default class RequestPipelineContext { this.isFetch = false; this.isPage = false; this.isHtmlImport = false; + this.isWebSocket = false; this.isIframe = false; this.isSpecialPage = false; this.contentInfo = null; @@ -55,7 +56,9 @@ export default class RequestPipelineContext { isScript: parsedResourceType.isScript, isEventSource: parsedResourceType.isEventSource, isHtmlImport: parsedResourceType.isHtmlImport, - charset: parsed.charset + isWebSocket: parsedResourceType.isWebSocket, + charset: parsed.charset, + reqOrigin: parsed.reqOrigin }; dest = this._omitDefaultPort(dest); @@ -115,6 +118,7 @@ export default class RequestPipelineContext { this.isPage = !this.isXhr && acceptHeader && contentTypeUtils.isPage(acceptHeader) || this.dest.isHtmlImport; this.isHtmlImport = this.dest.isHtmlImport; + this.isWebSocket = this.dest.isWebSocket; this.isIframe = this.dest.isIframe; this.isSpecialPage = urlUtils.isSpecialPage(this.dest.url); this.isFileProtocol = this.dest.protocol === 'file:'; @@ -244,7 +248,7 @@ export default class RequestPipelineContext { closeWithError (statusCode, resBody) { this.res.statusCode = statusCode; - if (resBody && !this.res.headersSent) { + if (resBody && !this.res.headersSent && this.res.setHeader) { this.res.setHeader('content-type', 'text/html'); this.res.end(resBody); } diff --git a/src/request-pipeline/destination-request/index.js b/src/request-pipeline/destination-request/index.js index dd683d9c4..0607d2633 100644 --- a/src/request-pipeline/destination-request/index.js +++ b/src/request-pipeline/destination-request/index.js @@ -51,6 +51,7 @@ export default class DestinationRequest extends EventEmitter { this.req.on('response', res => this._onResponse(res)); this.req.on('error', err => this._onError(err)); + this.req.on('upgrade', (res, socket, head) => this._onUpgrade(res, socket, head)); this.req.setTimeout(timeout, () => this._onTimeout()); this.req.write(this.opts.body); this.req.end(); @@ -82,6 +83,13 @@ export default class DestinationRequest extends EventEmitter { } } + _onUpgrade (res, socket, head) { + if (head && head.length) + socket.unshift(head); + + this._onResponse(res); + } + async _resendWithCredentials (res) { addCredentials(this.opts.credentials, this.opts, res, this.protocolInterface); this.credentialsSent = true; diff --git a/src/request-pipeline/index.js b/src/request-pipeline/index.js index 74edac16c..dfe1f2b85 100644 --- a/src/request-pipeline/index.js +++ b/src/request-pipeline/index.js @@ -8,6 +8,7 @@ import connectionResetGuard from './connection-reset-guard'; import { check as checkSameOriginPolicy, SAME_ORIGIN_CHECK_FAILED_STATUS_CODE } from './xhr/same-origin-policy'; import { fetchBody, respond404 } from '../utils/http'; import { inject as injectUpload } from '../upload'; +import { respondOnWebSocket } from './websocket'; const EVENT_SOURCE_REQUEST_TIMEOUT = 60 * 60 * 1000; @@ -61,8 +62,13 @@ const stages = { if (ctx.contentInfo.requireProcessing && ctx.destRes.statusCode === 204) ctx.destRes.statusCode = 200; + if (ctx.isWebSocket) { + respondOnWebSocket(ctx); + + return; + } // NOTE: Just pipe the content body to the browser if we don't need to process it. - if (!ctx.contentInfo.requireProcessing) { + else if (!ctx.contentInfo.requireProcessing) { sendResponseHeaders(ctx); if (!ctx.isSpecialPage) { diff --git a/src/request-pipeline/websocket.js b/src/request-pipeline/websocket.js new file mode 100644 index 000000000..61e4d6c55 --- /dev/null +++ b/src/request-pipeline/websocket.js @@ -0,0 +1,32 @@ +import * as headerTransforms from './header-transforms'; + +function writeWebSocketHead (socket, destRes, headers) { + const { httpVersion, statusCode, statusMessage } = destRes; + + const resRaw = [`HTTP/${httpVersion} ${statusCode} ${statusMessage}`]; + const headersNames = Object.keys(headers); + + for (const headerName of headersNames) { + const headerValue = headers[headerName]; + + if (Array.isArray(headerValue)) { + for (const value of headerValue) + resRaw.push(headerName + ': ' + value); + } + else + resRaw.push(headerName + ': ' + headerValue); + } + + resRaw.push('', ''); + + socket.write(resRaw.join('\r\n')); +} + +export function respondOnWebSocket (ctx) { + const headers = headerTransforms.forResponse(ctx); + + writeWebSocketHead(ctx.res, ctx.destRes, headers); + + ctx.destRes.socket.pipe(ctx.res); + ctx.res.pipe(ctx.destRes.socket); +} diff --git a/src/utils/url.js b/src/utils/url.js index 79d7cbed9..0bf6c2715 100644 --- a/src/utils/url.js +++ b/src/utils/url.js @@ -13,7 +13,7 @@ const PORT_RE = /:([0-9]*)$/; const QUERY_AND_HASH_RE = /(\?.+|#[^#]*)$/; const PATH_AFTER_HOST_RE = /^\/([^\/]+?)\/([\S\s]+)$/; -export const SUPPORTED_PROTOCOL_RE = /^(https?|file):/i; +export const SUPPORTED_PROTOCOL_RE = /^(?:https?|file):/i; export const HASH_RE = /^#/; export const REQUEST_DESCRIPTOR_VALUES_SEPARATOR = '!'; export const SPECIAL_PAGES = ['about:blank', 'about:error']; @@ -25,7 +25,8 @@ export function parseResourceType (resourceType) { isForm: false, isScript: false, isEventSource: false, - isHtmlImport: false + isHtmlImport: false, + isWebSocket: false }; } @@ -34,15 +35,16 @@ export function parseResourceType (resourceType) { isForm: /f/.test(resourceType), isScript: /s/.test(resourceType), isEventSource: /e/.test(resourceType), - isHtmlImport: /h/.test(resourceType) + isHtmlImport: /h/.test(resourceType), + isWebSocket: /w/.test(resourceType) }; } export function getResourceTypeString (resourceType) { resourceType = resourceType || {}; - if (!resourceType.isIframe && !resourceType.isForm && !resourceType.isScript && - !resourceType.isEventSource && !resourceType.isHtmlImport) + if (!resourceType.isIframe && !resourceType.isForm && !resourceType.isScript && !resourceType.isEventSource && + !resourceType.isHtmlImport && !resourceType.isWebSocket) return null; return [ @@ -50,7 +52,8 @@ export function getResourceTypeString (resourceType) { resourceType.isForm ? 'f' : '', resourceType.isScript ? 's' : '', resourceType.isEventSource ? 'e' : '', - resourceType.isHtmlImport ? 'h' : '' + resourceType.isHtmlImport ? 'h' : '', + resourceType.isWebSocket ? 'w' : '' ].join(''); } @@ -115,9 +118,15 @@ export function getProxyUrl (url, opts) { if (opts.charset) params.push(opts.charset.toLowerCase()); + if (opts.reqOrigin) + params.push(opts.reqOrigin); + params = params.join(REQUEST_DESCRIPTOR_VALUES_SEPARATOR); - return 'http://' + opts.proxyHostname + ':' + opts.proxyPort + '/' + params + '/' + convertHostToLowerCase(url); + const proxyProtocol = opts.proxyProtocol || 'http:'; + + return proxyProtocol + '//' + opts.proxyHostname + ':' + opts.proxyPort + '/' + params + '/' + + convertHostToLowerCase(url); } export function getDomain (parsed) { @@ -129,6 +138,29 @@ export function getDomain (parsed) { }); } +function parseRequestDescriptor (desc) { + const params = desc.split(REQUEST_DESCRIPTOR_VALUES_SEPARATOR); + + if (!params.length) + return null; + + const sessionId = params[0]; + const resourceType = params[1] || null; + const resourceData = params[2] || null; + const parsedDesc = { sessionId, resourceType }; + + if (resourceType && resourceData) { + const parsedResourceType = parseResourceType(resourceType); + + if (parsedResourceType.isScript) + parsedDesc.charset = resourceData; + else if (parsedResourceType.isWebSocket) + parsedDesc.reqOrigin = decodeURIComponent(resourceData); + } + + return parsedDesc; +} + export function parseProxyUrl (proxyUrl) { // TODO: Remove it. const parsedUrl = parseUrl(proxyUrl); @@ -141,10 +173,10 @@ export function parseProxyUrl (proxyUrl) { if (!match) return null; - const params = match[1].split(REQUEST_DESCRIPTOR_VALUES_SEPARATOR); + const parsedDesc = parseRequestDescriptor(match[1]); // NOTE: We should have, at least, the job uid and the owner token. - if (!params.length) + if (!parsedDesc) return null; const destUrl = match[2]; @@ -170,9 +202,10 @@ export function parseProxyUrl (proxyUrl) { port: parsedUrl.port }, - sessionId: params[0], - resourceType: params[1] || null, - charset: params[2] || null + sessionId: parsedDesc.sessionId, + resourceType: parsedDesc.resourceType, + charset: parsedDesc.charset, + reqOrigin: parsedDesc.reqOrigin }; } diff --git a/test/client/fixtures/sandbox/node/classes-test.js b/test/client/fixtures/sandbox/node/classes-test.js index 58d433dbc..8004c393e 100644 --- a/test/client/fixtures/sandbox/node/classes-test.js +++ b/test/client/fixtures/sandbox/node/classes-test.js @@ -89,10 +89,12 @@ if (browserUtils.isIE) { } if (window.EventSource) { - test('should work with the operator "instanceof" (GH-690)', function () { + test('should work with the "instanceof" operator (GH-690)', function () { var eventSource = new EventSource(''); ok(eventSource instanceof EventSource); + + eventSource.close(); }); test('should have static constants (GH-1106)', function () { @@ -149,11 +151,12 @@ if (window.EventSource) { }); } -if (window.MutationObserver) { - module('MutationObserver'); +module('MutationObserver'); +if (window.MutationObserver) { test('should work with the operator "instanceof" (GH-690)', function () { - var observer = new MutationObserver(function () { }); + var observer = new MutationObserver(function () { + }); ok(observer instanceof MutationObserver); }); @@ -178,6 +181,124 @@ if (window.MutationObserver) { }); } +module('WebSocket'); + +if (window.WebSocket) { + test('constructor', function () { + var socket = new WebSocket('ws://' + location.host); + + ok(socket instanceof WebSocket); + + strictEqual(WebSocket.CONNECTING, socket.CONNECTING); + strictEqual(WebSocket.OPEN, socket.OPEN); + strictEqual(WebSocket.CLOSING, socket.CLOSING); + strictEqual(WebSocket.CLOSED, socket.CLOSED); + + socket.close(); + }); + + test('url property', function () { + var url = 'ws://' + location.host; + var socket = new WebSocket(url); + + strictEqual(socket.url, url); + + socket.close(); + + var secureUrl = 'wss://' + location.host; + var secureSocket = new WebSocket(secureUrl); + + strictEqual(secureSocket.url, secureUrl); + + secureSocket.close(); + }); + + test('origin property of MessageEvent', function () { + var event = nativeMethods.documentCreateEvent.call(document, 'MessageEvent'); + var storedAddEventListener = WebSocket.prototype.addEventListener; + + WebSocket.prototype.addEventListener = function (type, fn) { + fn(event); + }; + + var socket = new WebSocket('ws://example.com'); + + event.__defineGetter__('target', function () { + return socket; + }); + + strictEqual(event.origin, 'ws://example.com'); + + socket.close(); + + WebSocket.prototype.addEventListener = storedAddEventListener; + }); + + test('checking parameters', function () { + var nativeWebSocket = nativeMethods.WebSocket; + var originHeader = encodeURIComponent(destLocation.getOriginHeader()); + var addEventListener = function () { + }; + + nativeMethods.WebSocket = function (url) { + strictEqual(url, 'ws://' + location.host + '/sessionId!w!' + originHeader + '/http://localhost/socket'); + }; + + nativeMethods.WebSocket.prototype.addEventListener = addEventListener; + + /* eslint-disable no-new */ + new WebSocket('ws://localhost/socket'); + + nativeMethods.WebSocket = function (url) { + strictEqual(url, 'ws://' + location.host + '/sessionId!w!' + originHeader + + '/https://localhost/secure-socket'); + }; + + nativeMethods.WebSocket.prototype.addEventListener = addEventListener; + + new WebSocket('wss://localhost/secure-socket'); + new WebSocket('wss://localhost/secure-socket', ['soap']); + + nativeMethods.WebSocket = function (url) { + strictEqual(arguments.length, 3); + strictEqual(url, 'ws://' + location.host + '/sessionId!w!' + originHeader + + '/https://localhost/secure-socket'); + }; + + nativeMethods.WebSocket.prototype.addEventListener = addEventListener; + + new WebSocket('wss://localhost/secure-socket', ['soap'], 123); + new WebSocket('wss://localhost/secure-socket', ['soap'], 123, 'str'); + /* eslint-enable no-new */ + + nativeMethods.WebSocket = nativeWebSocket; + }); + + /* eslint-disable no-new */ + test('throwing errors', function () { + throws(function () { + new WebSocket(); + }); + + throws(function () { + new WebSocket(''); + }); + + throws(function () { + new WebSocket('/path'); + }); + + throws(function () { + new WebSocket('//example.com'); + }); + + throws(function () { + new WebSocket('http://example.com'); + }); + }); + /* eslint-enable no-new */ +} + module('regression'); if (window.Blob) { diff --git a/test/client/fixtures/utils/dom-test.js b/test/client/fixtures/utils/dom-test.js index 0163f6fff..87dca1773 100644 --- a/test/client/fixtures/utils/dom-test.js +++ b/test/client/fixtures/utils/dom-test.js @@ -172,6 +172,18 @@ test('isDocument (GH-1344)', function () { ok(!domUtils.isDocument(new Proxy({}, {}))); }); +test('isWebSocket', function () { + var webSocket = new WebSocket('ws://127.0.0.1:2000/'); + + ok(domUtils.isWebSocket(webSocket)); + ok(!domUtils.isWebSocket(document)); + ok(!domUtils.isWebSocket({})); + ok(!domUtils.isWebSocket({ url: 'ws://127.0.0.1:2000/' })); + ok(!domUtils.isWebSocket(null)); + + webSocket.close(); +}); + test('getTopSameDomainWindow', function () { return createTestIframe() .then(function (iframe) { diff --git a/test/server/proxy-test.js b/test/server/proxy-test.js index 67ef4dca8..010949df5 100644 --- a/test/server/proxy-test.js +++ b/test/server/proxy-test.js @@ -12,6 +12,7 @@ const express = require('express'); const read = require('read-file-relative').readSync; const createSelfSignedHttpsServer = require('self-signed-https'); const getFreePort = require('endpoint-utils').getFreePort; +const WebSocket = require('ws'); const XHR_HEADERS = require('../../lib/request-pipeline/xhr/headers'); const AUTHORIZATION = require('../../lib/request-pipeline/xhr/authorization'); const SAME_ORIGIN_CHECK_FAILED_STATUS_CODE = require('../../lib/request-pipeline/xhr/same-origin-policy').SAME_ORIGIN_CHECK_FAILED_STATUS_CODE; @@ -1414,6 +1415,187 @@ describe('Proxy', () => { }); }); + describe('WebSocket', () => { + let httpsServer = null; + let wsServer = null; + let wssServer = null; + + before(() => { + httpsServer = createSelfSignedHttpsServer(() => { + }).listen(2001); + wsServer = new WebSocket.Server({ + server: destServer, + path: '/web-socket' + }); + wssServer = new WebSocket.Server({ + server: httpsServer, + path: '/secire-web-socket' + }); + + const wsConnectionHandler = (ws, req) => { + ws.on('message', msg => { + if (msg === 'get origin header') + ws.send(req.headers['origin']); + else if (msg === 'get cookie header') + ws.send(req.headers['cookie']); + else + ws.send(msg); + }); + }; + + wsServer.on('connection', wsConnectionHandler); + wssServer.on('connection', wsConnectionHandler); + }); + + after(() => { + wsServer.close(); + wssServer.close(); + httpsServer.close(); + }); + + const askSocket = (ws, msg) => { + return new Promise(resolve => { + ws.once('message', resolve); + ws.send(msg); + }); + }; + + it('Should proxy WebSocket', () => { + const url = urlUtils.getProxyUrl('http://127.0.0.1:2000/web-socket', { + proxyHostname: '127.0.0.1', + proxyPort: 1836, + sessionId: session.id, + resourceType: urlUtils.getResourceTypeString({ isWebSocket: true }), + reqOrigin: encodeURIComponent('http://example.com') + }); + + proxy.openSession('http://127.0.0.1:2000/', session); + session.cookies.setByClient('http://127.0.0.1:2000', 'key=value'); + + const ws = new WebSocket(url, { origin: 'http://some.domain.url' }); + + return new Promise(resolve => { + ws.on('open', resolve); + }) + .then(() => { + return askSocket(ws, 'get origin header'); + }) + .then(msg => { + expect(msg).eql('http://example.com'); + + return askSocket(ws, 'get cookie header'); + }) + .then(msg => { + expect(msg).eql('key=value'); + + return askSocket(ws, 'echo'); + }) + .then(msg => { + expect(msg).eql('echo'); + + ws.close(); + }); + }); + + it('Should proxy secure WebSocket', () => { + const url = urlUtils.getProxyUrl('https://127.0.0.1:2001/secire-web-socket', { + proxyHostname: '127.0.0.1', + proxyPort: 1836, + sessionId: session.id, + resourceType: urlUtils.getResourceTypeString({ isWebSocket: true }), + reqOrigin: encodeURIComponent('http://example.com') + }); + + proxy.openSession('https://127.0.0.1:2001/', session); + + const ws = new WebSocket(url, { origin: 'http://some.domain.url' }); + + return new Promise(resolve => { + ws.on('open', resolve); + }) + .then(() => { + return askSocket(ws, 'get origin header'); + }) + .then(msg => { + expect(msg).eql('http://example.com'); + + ws.close(); + }); + }); + + it('Should not throws an proxy error when server is not available', (done) => { + const url = urlUtils.getProxyUrl('http://127.0.0.1:2003/ws', { + proxyHostname: '127.0.0.1', + proxyPort: 1836, + sessionId: session.id, + resourceType: urlUtils.getResourceTypeString({ isWebSocket: true }), + reqOrigin: encodeURIComponent('http://example.com') + }); + + proxy.openSession('http://127.0.0.1:2003/', session); + + const ws = new WebSocket(url); + + ws.on('error', err => { + expect(err.message).eql('socket hang up'); + }); + + ws.on('close', () => { + done(); + }); + }); + + it('Should close webSocket from server side', function (done) { + getFreePort() + .then(port => { + const url = urlUtils.getProxyUrl('http://127.0.0.1:' + port, { + proxyHostname: '127.0.0.1', + proxyPort: 1836, + sessionId: session.id, + resourceType: urlUtils.getResourceTypeString({ isWebSocket: true }) + }); + + proxy.openSession('http://127.0.0.1:2000/', session); + + const wsTemporaryServer = new WebSocket.Server({ port }, () => { + const ws = new WebSocket(url); + + ws.on('close', code => { + expect(code).eql(1013); + wsTemporaryServer.close(done); + }); + }); + + wsTemporaryServer.on('connection', ws => ws.close(1013)); + }); + }); + + it('Should exposes number of bytes received', function (done) { + getFreePort() + .then(port => { + const url = urlUtils.getProxyUrl('http://localhost:' + port, { + proxyHostname: '127.0.0.1', + proxyPort: 1836, + sessionId: session.id, + resourceType: urlUtils.getResourceTypeString({ isWebSocket: true }) + }); + + proxy.openSession('http://127.0.0.1:2000/', session); + + const wsTemporaryServer = new WebSocket.Server({ port: port }, () => { + const ws = new WebSocket(url); + + ws.on('message', () => { + expect(ws.bytesReceived).eql(8); + wsTemporaryServer.close(done); + }); + }); + + wsTemporaryServer.on('connection', ws => ws.send('foobar')); + }); + }); + }); + describe('Regression', () => { it('Should force "Origin" header for the same-domain requests (B234325)', done => { const options = {