diff options
author | Max Romanov <max.romanov@nginx.com> | 2019-08-20 16:32:05 +0300 |
---|---|---|
committer | Max Romanov <max.romanov@nginx.com> | 2019-08-20 16:32:05 +0300 |
commit | e291841b3379f8787a10ad4f91e4aeae2ae323a4 (patch) | |
tree | 49a2f4629e5b8d6cd48f7436d7eeba4c99905675 /src/nodejs/unit-http/websocket_request.js | |
parent | e501c74ddceab86e48c031ca9b5e154f52dcdae0 (diff) | |
download | unit-e291841b3379f8787a10ad4f91e4aeae2ae323a4.tar.gz unit-e291841b3379f8787a10ad4f91e4aeae2ae323a4.tar.bz2 |
Node.js: introducing websocket support.
Diffstat (limited to '')
-rw-r--r-- | src/nodejs/unit-http/websocket_request.js | 509 |
1 files changed, 509 insertions, 0 deletions
diff --git a/src/nodejs/unit-http/websocket_request.js b/src/nodejs/unit-http/websocket_request.js new file mode 100644 index 00000000..d84e428b --- /dev/null +++ b/src/nodejs/unit-http/websocket_request.js @@ -0,0 +1,509 @@ +/************************************************************************ + * Copyright 2010-2015 Brian McKelvey. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + ***********************************************************************/ + +var util = require('util'); +var url = require('url'); +var EventEmitter = require('events').EventEmitter; +var WebSocketConnection = require('./websocket_connection'); + +var headerValueSplitRegExp = /,\s*/; +var headerParamSplitRegExp = /;\s*/; +var headerSanitizeRegExp = /[\r\n]/g; +var xForwardedForSeparatorRegExp = /,\s*/; +var separators = [ + '(', ')', '<', '>', '@', + ',', ';', ':', '\\', '\"', + '/', '[', ']', '?', '=', + '{', '}', ' ', String.fromCharCode(9) +]; +var controlChars = [String.fromCharCode(127) /* DEL */]; +for (var i=0; i < 31; i ++) { + /* US-ASCII Control Characters */ + controlChars.push(String.fromCharCode(i)); +} + +var cookieNameValidateRegEx = /([\x00-\x20\x22\x28\x29\x2c\x2f\x3a-\x3f\x40\x5b-\x5e\x7b\x7d\x7f])/; +var cookieValueValidateRegEx = /[^\x21\x23-\x2b\x2d-\x3a\x3c-\x5b\x5d-\x7e]/; +var cookieValueDQuoteValidateRegEx = /^"[^"]*"$/; +var controlCharsAndSemicolonRegEx = /[\x00-\x20\x3b]/g; + +var cookieSeparatorRegEx = /[;,] */; + +var httpStatusDescriptions = { + 100: 'Continue', + 101: 'Switching Protocols', + 200: 'OK', + 201: 'Created', + 203: 'Non-Authoritative Information', + 204: 'No Content', + 205: 'Reset Content', + 206: 'Partial Content', + 300: 'Multiple Choices', + 301: 'Moved Permanently', + 302: 'Found', + 303: 'See Other', + 304: 'Not Modified', + 305: 'Use Proxy', + 307: 'Temporary Redirect', + 400: 'Bad Request', + 401: 'Unauthorized', + 402: 'Payment Required', + 403: 'Forbidden', + 404: 'Not Found', + 406: 'Not Acceptable', + 407: 'Proxy Authorization Required', + 408: 'Request Timeout', + 409: 'Conflict', + 410: 'Gone', + 411: 'Length Required', + 412: 'Precondition Failed', + 413: 'Request Entity Too Long', + 414: 'Request-URI Too Long', + 415: 'Unsupported Media Type', + 416: 'Requested Range Not Satisfiable', + 417: 'Expectation Failed', + 426: 'Upgrade Required', + 500: 'Internal Server Error', + 501: 'Not Implemented', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + 504: 'Gateway Timeout', + 505: 'HTTP Version Not Supported' +}; + +function WebSocketRequest(socket, httpRequest, serverConfig) { + // Superclass Constructor + EventEmitter.call(this); + + this.socket = socket; + this.httpRequest = httpRequest; + this.resource = httpRequest.url; + this.remoteAddress = socket.remoteAddress; + this.remoteAddresses = [this.remoteAddress]; + this.serverConfig = serverConfig; + + // Watch for the underlying TCP socket closing before we call accept + this._socketIsClosing = false; + this._socketCloseHandler = this._handleSocketCloseBeforeAccept.bind(this); + this.socket.on('end', this._socketCloseHandler); + this.socket.on('close', this._socketCloseHandler); + + this._resolved = false; +} + +util.inherits(WebSocketRequest, EventEmitter); + +WebSocketRequest.prototype.readHandshake = function() { + var self = this; + var request = this.httpRequest; + + // Decode URL + this.resourceURL = url.parse(this.resource, true); + + this.host = request.headers['host']; + if (!this.host) { + throw new Error('Client must provide a Host header.'); + } + + this.key = request.headers['sec-websocket-key']; + if (!this.key) { + throw new Error('Client must provide a value for Sec-WebSocket-Key.'); + } + + this.webSocketVersion = parseInt(request.headers['sec-websocket-version'], 10); + + if (!this.webSocketVersion || isNaN(this.webSocketVersion)) { + throw new Error('Client must provide a value for Sec-WebSocket-Version.'); + } + + switch (this.webSocketVersion) { + case 8: + case 13: + break; + default: + var e = new Error('Unsupported websocket client version: ' + this.webSocketVersion + + 'Only versions 8 and 13 are supported.'); + e.httpCode = 426; + e.headers = { + 'Sec-WebSocket-Version': '13' + }; + throw e; + } + + if (this.webSocketVersion === 13) { + this.origin = request.headers['origin']; + } + else if (this.webSocketVersion === 8) { + this.origin = request.headers['sec-websocket-origin']; + } + + // Protocol is optional. + var protocolString = request.headers['sec-websocket-protocol']; + this.protocolFullCaseMap = {}; + this.requestedProtocols = []; + if (protocolString) { + var requestedProtocolsFullCase = protocolString.split(headerValueSplitRegExp); + requestedProtocolsFullCase.forEach(function(protocol) { + var lcProtocol = protocol.toLocaleLowerCase(); + self.requestedProtocols.push(lcProtocol); + self.protocolFullCaseMap[lcProtocol] = protocol; + }); + } + + if (!this.serverConfig.ignoreXForwardedFor && + request.headers['x-forwarded-for']) { + var immediatePeerIP = this.remoteAddress; + this.remoteAddresses = request.headers['x-forwarded-for'] + .split(xForwardedForSeparatorRegExp); + this.remoteAddresses.push(immediatePeerIP); + this.remoteAddress = this.remoteAddresses[0]; + } + + // Extensions are optional. + var extensionsString = request.headers['sec-websocket-extensions']; + this.requestedExtensions = this.parseExtensions(extensionsString); + + // Cookies are optional + var cookieString = request.headers['cookie']; + this.cookies = this.parseCookies(cookieString); +}; + +WebSocketRequest.prototype.parseExtensions = function(extensionsString) { + if (!extensionsString || extensionsString.length === 0) { + return []; + } + var extensions = extensionsString.toLocaleLowerCase().split(headerValueSplitRegExp); + extensions.forEach(function(extension, index, array) { + var params = extension.split(headerParamSplitRegExp); + var extensionName = params[0]; + var extensionParams = params.slice(1); + extensionParams.forEach(function(rawParam, index, array) { + var arr = rawParam.split('='); + var obj = { + name: arr[0], + value: arr[1] + }; + array.splice(index, 1, obj); + }); + var obj = { + name: extensionName, + params: extensionParams + }; + array.splice(index, 1, obj); + }); + return extensions; +}; + +// This function adapted from node-cookie +// https://github.com/shtylman/node-cookie +WebSocketRequest.prototype.parseCookies = function(str) { + // Sanity Check + if (!str || typeof(str) !== 'string') { + return []; + } + + var cookies = []; + var pairs = str.split(cookieSeparatorRegEx); + + pairs.forEach(function(pair) { + var eq_idx = pair.indexOf('='); + if (eq_idx === -1) { + cookies.push({ + name: pair, + value: null + }); + return; + } + + var key = pair.substr(0, eq_idx).trim(); + var val = pair.substr(++eq_idx, pair.length).trim(); + + // quoted values + if ('"' === val[0]) { + val = val.slice(1, -1); + } + + cookies.push({ + name: key, + value: decodeURIComponent(val) + }); + }); + + return cookies; +}; + +WebSocketRequest.prototype.accept = function(acceptedProtocol, allowedOrigin, cookies) { + this._verifyResolution(); + + // TODO: Handle extensions + + var protocolFullCase; + + if (acceptedProtocol) { + protocolFullCase = this.protocolFullCaseMap[acceptedProtocol.toLocaleLowerCase()]; + if (typeof(protocolFullCase) === 'undefined') { + protocolFullCase = acceptedProtocol; + } + } + else { + protocolFullCase = acceptedProtocol; + } + this.protocolFullCaseMap = null; + + var response = this.httpRequest._response; + response.statusCode = 101; + + if (protocolFullCase) { + // validate protocol + for (var i=0; i < protocolFullCase.length; i++) { + var charCode = protocolFullCase.charCodeAt(i); + var character = protocolFullCase.charAt(i); + if (charCode < 0x21 || charCode > 0x7E || separators.indexOf(character) !== -1) { + this.reject(500); + throw new Error('Illegal character "' + String.fromCharCode(character) + '" in subprotocol.'); + } + } + if (this.requestedProtocols.indexOf(acceptedProtocol) === -1) { + this.reject(500); + throw new Error('Specified protocol was not requested by the client.'); + } + + protocolFullCase = protocolFullCase.replace(headerSanitizeRegExp, ''); + response += 'Sec-WebSocket-Protocol: ' + protocolFullCase + '\r\n'; + } + this.requestedProtocols = null; + + if (allowedOrigin) { + allowedOrigin = allowedOrigin.replace(headerSanitizeRegExp, ''); + if (this.webSocketVersion === 13) { + response.setHeader('Origin', allowedOrigin); + } + else if (this.webSocketVersion === 8) { + response.setHeader('Sec-WebSocket-Origin', allowedOrigin); + } + } + + if (cookies) { + if (!Array.isArray(cookies)) { + this.reject(500); + throw new Error('Value supplied for "cookies" argument must be an array.'); + } + var seenCookies = {}; + cookies.forEach(function(cookie) { + if (!cookie.name || !cookie.value) { + this.reject(500); + throw new Error('Each cookie to set must at least provide a "name" and "value"'); + } + + // Make sure there are no \r\n sequences inserted + cookie.name = cookie.name.replace(controlCharsAndSemicolonRegEx, ''); + cookie.value = cookie.value.replace(controlCharsAndSemicolonRegEx, ''); + + if (seenCookies[cookie.name]) { + this.reject(500); + throw new Error('You may not specify the same cookie name twice.'); + } + seenCookies[cookie.name] = true; + + // token (RFC 2616, Section 2.2) + var invalidChar = cookie.name.match(cookieNameValidateRegEx); + if (invalidChar) { + this.reject(500); + throw new Error('Illegal character ' + invalidChar[0] + ' in cookie name'); + } + + // RFC 6265, Section 4.1.1 + // *cookie-octet / ( DQUOTE *cookie-octet DQUOTE ) | %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E + if (cookie.value.match(cookieValueDQuoteValidateRegEx)) { + invalidChar = cookie.value.slice(1, -1).match(cookieValueValidateRegEx); + } else { + invalidChar = cookie.value.match(cookieValueValidateRegEx); + } + if (invalidChar) { + this.reject(500); + throw new Error('Illegal character ' + invalidChar[0] + ' in cookie value'); + } + + var cookieParts = [cookie.name + '=' + cookie.value]; + + // RFC 6265, Section 4.1.1 + // 'Path=' path-value | <any CHAR except CTLs or ';'> + if(cookie.path){ + invalidChar = cookie.path.match(controlCharsAndSemicolonRegEx); + if (invalidChar) { + this.reject(500); + throw new Error('Illegal character ' + invalidChar[0] + ' in cookie path'); + } + cookieParts.push('Path=' + cookie.path); + } + + // RFC 6265, Section 4.1.2.3 + // 'Domain=' subdomain + if (cookie.domain) { + if (typeof(cookie.domain) !== 'string') { + this.reject(500); + throw new Error('Domain must be specified and must be a string.'); + } + invalidChar = cookie.domain.match(controlCharsAndSemicolonRegEx); + if (invalidChar) { + this.reject(500); + throw new Error('Illegal character ' + invalidChar[0] + ' in cookie domain'); + } + cookieParts.push('Domain=' + cookie.domain.toLowerCase()); + } + + // RFC 6265, Section 4.1.1 + //'Expires=' sane-cookie-date | Force Date object requirement by using only epoch + if (cookie.expires) { + if (!(cookie.expires instanceof Date)){ + this.reject(500); + throw new Error('Value supplied for cookie "expires" must be a vaild date object'); + } + cookieParts.push('Expires=' + cookie.expires.toGMTString()); + } + + // RFC 6265, Section 4.1.1 + //'Max-Age=' non-zero-digit *DIGIT + if (cookie.maxage) { + var maxage = cookie.maxage; + if (typeof(maxage) === 'string') { + maxage = parseInt(maxage, 10); + } + if (isNaN(maxage) || maxage <= 0 ) { + this.reject(500); + throw new Error('Value supplied for cookie "maxage" must be a non-zero number'); + } + maxage = Math.round(maxage); + cookieParts.push('Max-Age=' + maxage.toString(10)); + } + + // RFC 6265, Section 4.1.1 + //'Secure;' + if (cookie.secure) { + if (typeof(cookie.secure) !== 'boolean') { + this.reject(500); + throw new Error('Value supplied for cookie "secure" must be of type boolean'); + } + cookieParts.push('Secure'); + } + + // RFC 6265, Section 4.1.1 + //'HttpOnly;' + if (cookie.httponly) { + if (typeof(cookie.httponly) !== 'boolean') { + this.reject(500); + throw new Error('Value supplied for cookie "httponly" must be of type boolean'); + } + cookieParts.push('HttpOnly'); + } + + response.addHeader('Set-Cookie', cookieParts.join(';')); + }.bind(this)); + } + + // TODO: handle negotiated extensions + // if (negotiatedExtensions) { + // response += 'Sec-WebSocket-Extensions: ' + negotiatedExtensions.join(', ') + '\r\n'; + // } + + // Mark the request resolved now so that the user can't call accept or + // reject a second time. + this._resolved = true; + this.emit('requestResolved', this); + + var connection = new WebSocketConnection(this.socket, [], acceptedProtocol, false, this.serverConfig); + connection.webSocketVersion = this.webSocketVersion; + connection.remoteAddress = this.remoteAddress; + connection.remoteAddresses = this.remoteAddresses; + + var self = this; + + if (this._socketIsClosing) { + // Handle case when the client hangs up before we get a chance to + // accept the connection and send our side of the opening handshake. + cleanupFailedConnection(connection); + + } else { + response._sendHeaders(); + connection._addSocketEventListeners(); + } + + this.emit('requestAccepted', connection); + return connection; +}; + +WebSocketRequest.prototype.reject = function(status, reason, extraHeaders) { + this._verifyResolution(); + + // Mark the request resolved now so that the user can't call accept or + // reject a second time. + this._resolved = true; + this.emit('requestResolved', this); + + if (typeof(status) !== 'number') { + status = 403; + } + + var response = this.httpRequest._response; + + response.statusCode = status; + + if (reason) { + reason = reason.replace(headerSanitizeRegExp, ''); + response.addHeader('X-WebSocket-Reject-Reason', reason); + } + + if (extraHeaders) { + for (var key in extraHeaders) { + var sanitizedValue = extraHeaders[key].toString().replace(headerSanitizeRegExp, ''); + var sanitizedKey = key.replace(headerSanitizeRegExp, ''); + response += (sanitizedKey + ': ' + sanitizedValue + '\r\n'); + } + } + + response.end(); + + this.emit('requestRejected', this); +}; + +WebSocketRequest.prototype._handleSocketCloseBeforeAccept = function() { + this._socketIsClosing = true; + this._removeSocketCloseListeners(); +}; + +WebSocketRequest.prototype._removeSocketCloseListeners = function() { + this.socket.removeListener('end', this._socketCloseHandler); + this.socket.removeListener('close', this._socketCloseHandler); +}; + +WebSocketRequest.prototype._verifyResolution = function() { + if (this._resolved) { + throw new Error('WebSocketRequest may only be accepted or rejected one time.'); + } +}; + +function cleanupFailedConnection(connection) { + // Since we have to return a connection object even if the socket is + // already dead in order not to break the API, we schedule a 'close' + // event on the connection object to occur immediately. + process.nextTick(function() { + // WebSocketConnection.CLOSE_REASON_ABNORMAL = 1006 + // Third param: Skip sending the close frame to a dead socket + connection.drop(1006, 'TCP connection lost before handshake completed.', true); + }); +} + +module.exports = WebSocketRequest; |