/************************************************************************ * 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 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; // Create key validation hash var 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 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 += '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 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 | 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 += ('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 socket connection.drop(1006, 'TCP connection lost before handshake completed.', true); }); } module.exports = WebSocketRequest;