Rev 891 | Rev 911 | Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | RSS feed
/************************************************************************* 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 crypto = require('crypto');var util = require('util');var url = require('url');var EventEmitter = require('events').EventEmitter;var WebSocketConnection = require('./WebSocketConnection');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 ConstructorEventEmitter.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 acceptthis._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 URLthis.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 optionalvar 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-cookieWebSocketRequest.prototype.parseCookies = function(str) {// Sanity Checkif (!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 valuesif ('"' === 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 extensionsvar protocolFullCase;if (acceptedProtocol) {protocolFullCase = this.protocolFullCaseMap[acceptedProtocol.toLocaleLowerCase()];if (typeof(protocolFullCase) === 'undefined') {protocolFullCase = acceptedProtocol;}}else {protocolFullCase = acceptedProtocol;}this.protocolFullCaseMap = null;// Create key validation hashvar sha1 = crypto.createHash('sha1');sha1.update(this.key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11');var acceptKey = sha1.digest('base64');var response = 'HTTP/1.1 101 Switching Protocols\r\n' +'Upgrade: websocket\r\n' +'Connection: Upgrade\r\n' +'Sec-WebSocket-Accept: ' + acceptKey + '\r\n';if (protocolFullCase) {// validate protocolfor (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 += 'Origin: ' + allowedOrigin + '\r\n';}else if (this.webSocketVersion === 8) {response += 'Sec-WebSocket-Origin: ' + allowedOrigin + '\r\n';}}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 insertedcookie.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-7Eif (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=' subdomainif (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 epochif (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 *DIGITif (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 += ('Set-Cookie: ' + cookieParts.join(';') + '\r\n');}.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);response += '\r\n';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 {this.socket.write(response, 'ascii', function(error) {if (error) {cleanupFailedConnection(connection);return;}self._removeSocketCloseListeners();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 = 'HTTP/1.1 ' + status + ' ' + httpStatusDescriptions[status] + '\r\n' +'Connection: close\r\n';if (reason) {reason = reason.replace(headerSanitizeRegExp, '');response += 'X-WebSocket-Reject-Reason: ' + reason + '\r\n';}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 += '\r\n';this.socket.end(response, 'ascii');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 socketconnection.drop(1006, 'TCP connection lost before handshake completed.', true);});}module.exports = WebSocketRequest;