1/************************************************************************ 2 * Copyright 2010-2015 Brian McKelvey. 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 ***********************************************************************/ 16 17var util = require('util'); 18var url = require('url'); 19var EventEmitter = require('events').EventEmitter; 20var WebSocketConnection = require('./websocket_connection'); 21 22var headerValueSplitRegExp = /,\s*/; 23var headerParamSplitRegExp = /;\s*/; 24var headerSanitizeRegExp = /[\r\n]/g; 25var xForwardedForSeparatorRegExp = /,\s*/; 26var separators = [ 27 '(', ')', '<', '>', '@', 28 ',', ';', ':', '\\', '\"', 29 '/', '[', ']', '?', '=', 30 '{', '}', ' ', String.fromCharCode(9) 31]; 32var controlChars = [String.fromCharCode(127) /* DEL */]; 33for (var i=0; i < 31; i ++) { 34 /* US-ASCII Control Characters */ 35 controlChars.push(String.fromCharCode(i)); 36} 37 38var cookieNameValidateRegEx = /([\x00-\x20\x22\x28\x29\x2c\x2f\x3a-\x3f\x40\x5b-\x5e\x7b\x7d\x7f])/; 39var cookieValueValidateRegEx = /[^\x21\x23-\x2b\x2d-\x3a\x3c-\x5b\x5d-\x7e]/; 40var cookieValueDQuoteValidateRegEx = /^"[^"]*"$/; 41var controlCharsAndSemicolonRegEx = /[\x00-\x20\x3b]/g; 42 43var cookieSeparatorRegEx = /[;,] */; 44 45var httpStatusDescriptions = { 46 100: 'Continue', 47 101: 'Switching Protocols', 48 200: 'OK', 49 201: 'Created', 50 203: 'Non-Authoritative Information', 51 204: 'No Content', 52 205: 'Reset Content', 53 206: 'Partial Content', 54 300: 'Multiple Choices', 55 301: 'Moved Permanently', 56 302: 'Found', 57 303: 'See Other', 58 304: 'Not Modified', 59 305: 'Use Proxy', 60 307: 'Temporary Redirect', 61 400: 'Bad Request', 62 401: 'Unauthorized', 63 402: 'Payment Required', 64 403: 'Forbidden', 65 404: 'Not Found', 66 406: 'Not Acceptable', 67 407: 'Proxy Authorization Required', 68 408: 'Request Timeout', 69 409: 'Conflict', 70 410: 'Gone', 71 411: 'Length Required', 72 412: 'Precondition Failed', 73 413: 'Request Entity Too Long', 74 414: 'Request-URI Too Long', 75 415: 'Unsupported Media Type', 76 416: 'Requested Range Not Satisfiable', 77 417: 'Expectation Failed', 78 426: 'Upgrade Required', 79 500: 'Internal Server Error', 80 501: 'Not Implemented', 81 502: 'Bad Gateway', 82 503: 'Service Unavailable', 83 504: 'Gateway Timeout', 84 505: 'HTTP Version Not Supported' 85}; 86 87function WebSocketRequest(socket, httpRequest, serverConfig) { 88 // Superclass Constructor 89 EventEmitter.call(this); 90 91 this.socket = socket; 92 this.httpRequest = httpRequest; 93 this.resource = httpRequest.url; 94 this.remoteAddress = socket.remoteAddress; 95 this.remoteAddresses = [this.remoteAddress]; 96 this.serverConfig = serverConfig; 97 98 // Watch for the underlying TCP socket closing before we call accept 99 this._socketIsClosing = false; 100 this._socketCloseHandler = this._handleSocketCloseBeforeAccept.bind(this); 101 this.socket.on('end', this._socketCloseHandler); 102 this.socket.on('close', this._socketCloseHandler); 103 104 this._resolved = false; 105} 106 107util.inherits(WebSocketRequest, EventEmitter); 108 109WebSocketRequest.prototype.readHandshake = function() { 110 var self = this; 111 var request = this.httpRequest; 112 113 // Decode URL 114 this.resourceURL = url.parse(this.resource, true); 115 116 this.host = request.headers['host']; 117 if (!this.host) { 118 throw new Error('Client must provide a Host header.'); 119 } 120 121 this.key = request.headers['sec-websocket-key']; 122 if (!this.key) { 123 throw new Error('Client must provide a value for Sec-WebSocket-Key.'); 124 } 125 126 this.webSocketVersion = parseInt(request.headers['sec-websocket-version'], 10); 127 128 if (!this.webSocketVersion || isNaN(this.webSocketVersion)) { 129 throw new Error('Client must provide a value for Sec-WebSocket-Version.'); 130 } 131 132 switch (this.webSocketVersion) { 133 case 8: 134 case 13: 135 break; 136 default: 137 var e = new Error('Unsupported websocket client version: ' + this.webSocketVersion + 138 'Only versions 8 and 13 are supported.'); 139 e.httpCode = 426; 140 e.headers = { 141 'Sec-WebSocket-Version': '13' 142 }; 143 throw e; 144 } 145 146 if (this.webSocketVersion === 13) { 147 this.origin = request.headers['origin']; 148 } 149 else if (this.webSocketVersion === 8) { 150 this.origin = request.headers['sec-websocket-origin']; 151 } 152 153 // Protocol is optional. 154 var protocolString = request.headers['sec-websocket-protocol']; 155 this.protocolFullCaseMap = {}; 156 this.requestedProtocols = []; 157 if (protocolString) { 158 var requestedProtocolsFullCase = protocolString.split(headerValueSplitRegExp); 159 requestedProtocolsFullCase.forEach(function(protocol) { 160 var lcProtocol = protocol.toLocaleLowerCase(); 161 self.requestedProtocols.push(lcProtocol); 162 self.protocolFullCaseMap[lcProtocol] = protocol; 163 }); 164 } 165 166 if (!this.serverConfig.ignoreXForwardedFor && 167 request.headers['x-forwarded-for']) { 168 var immediatePeerIP = this.remoteAddress; 169 this.remoteAddresses = request.headers['x-forwarded-for'] 170 .split(xForwardedForSeparatorRegExp); 171 this.remoteAddresses.push(immediatePeerIP); 172 this.remoteAddress = this.remoteAddresses[0]; 173 } 174 175 // Extensions are optional. 176 var extensionsString = request.headers['sec-websocket-extensions']; 177 this.requestedExtensions = this.parseExtensions(extensionsString); 178 179 // Cookies are optional 180 var cookieString = request.headers['cookie']; 181 this.cookies = this.parseCookies(cookieString); 182}; 183 184WebSocketRequest.prototype.parseExtensions = function(extensionsString) { 185 if (!extensionsString || extensionsString.length === 0) { 186 return []; 187 } 188 var extensions = extensionsString.toLocaleLowerCase().split(headerValueSplitRegExp); 189 extensions.forEach(function(extension, index, array) { 190 var params = extension.split(headerParamSplitRegExp); 191 var extensionName = params[0]; 192 var extensionParams = params.slice(1); 193 extensionParams.forEach(function(rawParam, index, array) { 194 var arr = rawParam.split('='); 195 var obj = { 196 name: arr[0], 197 value: arr[1] 198 }; 199 array.splice(index, 1, obj); 200 }); 201 var obj = { 202 name: extensionName, 203 params: extensionParams 204 }; 205 array.splice(index, 1, obj); 206 }); 207 return extensions; 208}; 209 210// This function adapted from node-cookie 211// https://github.com/shtylman/node-cookie 212WebSocketRequest.prototype.parseCookies = function(str) { 213 // Sanity Check 214 if (!str || typeof(str) !== 'string') { 215 return []; 216 } 217 218 var cookies = []; 219 var pairs = str.split(cookieSeparatorRegEx); 220 221 pairs.forEach(function(pair) { 222 var eq_idx = pair.indexOf('='); 223 if (eq_idx === -1) { 224 cookies.push({ 225 name: pair, 226 value: null 227 }); 228 return; 229 } 230 231 var key = pair.substr(0, eq_idx).trim(); 232 var val = pair.substr(++eq_idx, pair.length).trim(); 233 234 // quoted values 235 if ('"' === val[0]) { 236 val = val.slice(1, -1); 237 } 238 239 cookies.push({ 240 name: key, 241 value: decodeURIComponent(val) 242 }); 243 }); 244 245 return cookies; 246}; 247 248WebSocketRequest.prototype.accept = function(acceptedProtocol, allowedOrigin, cookies) { 249 this._verifyResolution(); 250 251 // TODO: Handle extensions 252 253 var protocolFullCase; 254 255 if (acceptedProtocol) { 256 protocolFullCase = this.protocolFullCaseMap[acceptedProtocol.toLocaleLowerCase()]; 257 if (typeof(protocolFullCase) === 'undefined') { 258 protocolFullCase = acceptedProtocol; 259 } 260 } 261 else { 262 protocolFullCase = acceptedProtocol; 263 } 264 this.protocolFullCaseMap = null; 265 266 var response = this.httpRequest._response; 267 response.statusCode = 101; 268 269 if (protocolFullCase) { 270 // validate protocol 271 for (var i=0; i < protocolFullCase.length; i++) { 272 var charCode = protocolFullCase.charCodeAt(i); 273 var character = protocolFullCase.charAt(i); 274 if (charCode < 0x21 || charCode > 0x7E || separators.indexOf(character) !== -1) { 275 this.reject(500); 276 throw new Error('Illegal character "' + String.fromCharCode(character) + '" in subprotocol.'); 277 } 278 } 279 if (this.requestedProtocols.indexOf(acceptedProtocol) === -1) { 280 this.reject(500); 281 throw new Error('Specified protocol was not requested by the client.'); 282 } 283 284 protocolFullCase = protocolFullCase.replace(headerSanitizeRegExp, ''); 285 response += 'Sec-WebSocket-Protocol: ' + protocolFullCase + '\r\n'; 286 } 287 this.requestedProtocols = null; 288 289 if (allowedOrigin) { 290 allowedOrigin = allowedOrigin.replace(headerSanitizeRegExp, ''); 291 if (this.webSocketVersion === 13) { 292 response.setHeader('Origin', allowedOrigin); 293 } 294 else if (this.webSocketVersion === 8) { 295 response.setHeader('Sec-WebSocket-Origin', allowedOrigin); 296 } 297 } 298 299 if (cookies) { 300 if (!Array.isArray(cookies)) { 301 this.reject(500); 302 throw new Error('Value supplied for "cookies" argument must be an array.'); 303 } 304 var seenCookies = {}; 305 cookies.forEach(function(cookie) { 306 if (!cookie.name || !cookie.value) { 307 this.reject(500); 308 throw new Error('Each cookie to set must at least provide a "name" and "value"'); 309 } 310 311 // Make sure there are no \r\n sequences inserted 312 cookie.name = cookie.name.replace(controlCharsAndSemicolonRegEx, ''); 313 cookie.value = cookie.value.replace(controlCharsAndSemicolonRegEx, ''); 314 315 if (seenCookies[cookie.name]) { 316 this.reject(500); 317 throw new Error('You may not specify the same cookie name twice.'); 318 } 319 seenCookies[cookie.name] = true; 320 321 // token (RFC 2616, Section 2.2) 322 var invalidChar = cookie.name.match(cookieNameValidateRegEx); 323 if (invalidChar) { 324 this.reject(500); 325 throw new Error('Illegal character ' + invalidChar[0] + ' in cookie name'); 326 } 327 328 // RFC 6265, Section 4.1.1 329 // *cookie-octet / ( DQUOTE *cookie-octet DQUOTE ) | %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E 330 if (cookie.value.match(cookieValueDQuoteValidateRegEx)) { 331 invalidChar = cookie.value.slice(1, -1).match(cookieValueValidateRegEx); 332 } else { 333 invalidChar = cookie.value.match(cookieValueValidateRegEx); 334 } 335 if (invalidChar) { 336 this.reject(500); 337 throw new Error('Illegal character ' + invalidChar[0] + ' in cookie value'); 338 } 339 340 var cookieParts = [cookie.name + '=' + cookie.value]; 341 342 // RFC 6265, Section 4.1.1 343 // 'Path=' path-value | <any CHAR except CTLs or ';'> 344 if(cookie.path){ 345 invalidChar = cookie.path.match(controlCharsAndSemicolonRegEx); 346 if (invalidChar) { 347 this.reject(500); 348 throw new Error('Illegal character ' + invalidChar[0] + ' in cookie path'); 349 } 350 cookieParts.push('Path=' + cookie.path); 351 } 352 353 // RFC 6265, Section 4.1.2.3 354 // 'Domain=' subdomain 355 if (cookie.domain) { 356 if (typeof(cookie.domain) !== 'string') { 357 this.reject(500); 358 throw new Error('Domain must be specified and must be a string.'); 359 } 360 invalidChar = cookie.domain.match(controlCharsAndSemicolonRegEx); 361 if (invalidChar) { 362 this.reject(500); 363 throw new Error('Illegal character ' + invalidChar[0] + ' in cookie domain'); 364 } 365 cookieParts.push('Domain=' + cookie.domain.toLowerCase()); 366 } 367 368 // RFC 6265, Section 4.1.1 369 //'Expires=' sane-cookie-date | Force Date object requirement by using only epoch 370 if (cookie.expires) { 371 if (!(cookie.expires instanceof Date)){ 372 this.reject(500); 373 throw new Error('Value supplied for cookie "expires" must be a vaild date object'); 374 } 375 cookieParts.push('Expires=' + cookie.expires.toGMTString()); 376 } 377 378 // RFC 6265, Section 4.1.1 379 //'Max-Age=' non-zero-digit *DIGIT 380 if (cookie.maxage) { 381 var maxage = cookie.maxage; 382 if (typeof(maxage) === 'string') { 383 maxage = parseInt(maxage, 10); 384 } 385 if (isNaN(maxage) || maxage <= 0 ) { 386 this.reject(500); 387 throw new Error('Value supplied for cookie "maxage" must be a non-zero number'); 388 } 389 maxage = Math.round(maxage); 390 cookieParts.push('Max-Age=' + maxage.toString(10)); 391 } 392 393 // RFC 6265, Section 4.1.1 394 //'Secure;' 395 if (cookie.secure) { 396 if (typeof(cookie.secure) !== 'boolean') { 397 this.reject(500); 398 throw new Error('Value supplied for cookie "secure" must be of type boolean'); 399 } 400 cookieParts.push('Secure'); 401 } 402 403 // RFC 6265, Section 4.1.1 404 //'HttpOnly;' 405 if (cookie.httponly) { 406 if (typeof(cookie.httponly) !== 'boolean') { 407 this.reject(500); 408 throw new Error('Value supplied for cookie "httponly" must be of type boolean'); 409 } 410 cookieParts.push('HttpOnly'); 411 } 412 413 response.addHeader('Set-Cookie', cookieParts.join(';')); 414 }.bind(this)); 415 } 416 417 // TODO: handle negotiated extensions 418 // if (negotiatedExtensions) { 419 // response += 'Sec-WebSocket-Extensions: ' + negotiatedExtensions.join(', ') + '\r\n'; 420 // } 421 422 // Mark the request resolved now so that the user can't call accept or 423 // reject a second time. 424 this._resolved = true; 425 this.emit('requestResolved', this); 426 427 var connection = new WebSocketConnection(this.socket, [], acceptedProtocol, false, this.serverConfig); 428 connection.webSocketVersion = this.webSocketVersion; 429 connection.remoteAddress = this.remoteAddress; 430 connection.remoteAddresses = this.remoteAddresses; 431 432 var self = this; 433 434 if (this._socketIsClosing) { 435 // Handle case when the client hangs up before we get a chance to 436 // accept the connection and send our side of the opening handshake. 437 cleanupFailedConnection(connection); 438 439 } else { 440 response._sendHeaders(); 441 connection._addSocketEventListeners(); 442 } 443 444 this.emit('requestAccepted', connection); 445 return connection; 446}; 447 448WebSocketRequest.prototype.reject = function(status, reason, extraHeaders) { 449 this._verifyResolution(); 450 451 // Mark the request resolved now so that the user can't call accept or 452 // reject a second time. 453 this._resolved = true; 454 this.emit('requestResolved', this); 455 456 if (typeof(status) !== 'number') { 457 status = 403; 458 } 459 460 var response = this.httpRequest._response; 461 462 response.statusCode = status; 463 464 if (reason) { 465 reason = reason.replace(headerSanitizeRegExp, ''); 466 response.addHeader('X-WebSocket-Reject-Reason', reason); 467 } 468 469 if (extraHeaders) { 470 for (var key in extraHeaders) { 471 var sanitizedValue = extraHeaders[key].toString().replace(headerSanitizeRegExp, ''); 472 var sanitizedKey = key.replace(headerSanitizeRegExp, ''); 473 response += (sanitizedKey + ': ' + sanitizedValue + '\r\n'); 474 } 475 } 476 477 response.end(); 478 479 this.emit('requestRejected', this); 480}; 481 482WebSocketRequest.prototype._handleSocketCloseBeforeAccept = function() { 483 this._socketIsClosing = true; 484 this._removeSocketCloseListeners(); 485}; 486 487WebSocketRequest.prototype._removeSocketCloseListeners = function() { 488 this.socket.removeListener('end', this._socketCloseHandler); 489 this.socket.removeListener('close', this._socketCloseHandler); 490}; 491 492WebSocketRequest.prototype._verifyResolution = function() { 493 if (this._resolved) { 494 throw new Error('WebSocketRequest may only be accepted or rejected one time.'); 495 } 496}; 497 498function cleanupFailedConnection(connection) { 499 // Since we have to return a connection object even if the socket is 500 // already dead in order not to break the API, we schedule a 'close' 501 // event on the connection object to occur immediately. 502 process.nextTick(function() { 503 // WebSocketConnection.CLOSE_REASON_ABNORMAL = 1006 504 // Third param: Skip sending the close frame to a dead socket 505 connection.drop(1006, 'TCP connection lost before handshake completed.', true); 506 }); 507} 508 509module.exports = WebSocketRequest; 510