1 /************************************************************************
2 * Copyright 2010-2015 Brian McKelvey.
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
8 * http://www.apache.org/licenses/LICENSE-2.0
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 ***********************************************************************/
17 var crypto = require('crypto');
18 var util = require('util');
19 var url = require('url');
20 var EventEmitter = require('events').EventEmitter;
21 var WebSocketConnection = require('./WebSocketConnection');
23 var headerValueSplitRegExp = /,\s*/;
24 var headerParamSplitRegExp = /;\s*/;
25 var headerSanitizeRegExp = /[\r\n]/g;
26 var xForwardedForSeparatorRegExp = /,\s*/;
28 '(', ')', '<', '>', '@',
29 ',', ';', ':', '\\', '\"',
30 '/', '[', ']', '?', '=',
31 '{', '}', ' ', String.fromCharCode(9)
33 var controlChars = [String.fromCharCode(127) /* DEL */];
34 for (var i=0; i < 31; i ++) {
35 /* US-ASCII Control Characters */
36 controlChars.push(String.fromCharCode(i));
39 var cookieNameValidateRegEx = /([\x00-\x20\x22\x28\x29\x2c\x2f\x3a-\x3f\x40\x5b-\x5e\x7b\x7d\x7f])/;
40 var cookieValueValidateRegEx = /[^\x21\x23-\x2b\x2d-\x3a\x3c-\x5b\x5d-\x7e]/;
41 var cookieValueDQuoteValidateRegEx = /^"[^"]*"$/;
42 var controlCharsAndSemicolonRegEx = /[\x00-\x20\x3b]/g;
44 var cookieSeparatorRegEx = /[;,] */;
46 var httpStatusDescriptions = {
48 101: 'Switching Protocols',
51 203: 'Non-Authoritative Information',
54 206: 'Partial Content',
55 300: 'Multiple Choices',
56 301: 'Moved Permanently',
61 307: 'Temporary Redirect',
64 402: 'Payment Required',
67 406: 'Not Acceptable',
68 407: 'Proxy Authorization Required',
69 408: 'Request Timeout',
72 411: 'Length Required',
73 412: 'Precondition Failed',
74 413: 'Request Entity Too Long',
75 414: 'Request-URI Too Long',
76 415: 'Unsupported Media Type',
77 416: 'Requested Range Not Satisfiable',
78 417: 'Expectation Failed',
79 426: 'Upgrade Required',
80 500: 'Internal Server Error',
81 501: 'Not Implemented',
83 503: 'Service Unavailable',
84 504: 'Gateway Timeout',
85 505: 'HTTP Version Not Supported'
88 function WebSocketRequest(socket, httpRequest, serverConfig) {
89 // Superclass Constructor
90 EventEmitter.call(this);
93 this.httpRequest = httpRequest;
94 this.resource = httpRequest.url;
95 this.remoteAddress = socket.remoteAddress;
96 this.remoteAddresses = [this.remoteAddress];
97 this.serverConfig = serverConfig;
99 // Watch for the underlying TCP socket closing before we call accept
100 this._socketIsClosing = false;
101 this._socketCloseHandler = this._handleSocketCloseBeforeAccept.bind(this);
102 this.socket.on('end', this._socketCloseHandler);
103 this.socket.on('close', this._socketCloseHandler);
105 this._resolved = false;
108 util.inherits(WebSocketRequest, EventEmitter);
110 WebSocketRequest.prototype.readHandshake = function() {
112 var request = this.httpRequest;
115 this.resourceURL = url.parse(this.resource, true);
117 this.host = request.headers['host'];
119 throw new Error('Client must provide a Host header.');
122 this.key = request.headers['sec-websocket-key'];
124 throw new Error('Client must provide a value for Sec-WebSocket-Key.');
127 this.webSocketVersion = parseInt(request.headers['sec-websocket-version'], 10);
129 if (!this.webSocketVersion || isNaN(this.webSocketVersion)) {
130 throw new Error('Client must provide a value for Sec-WebSocket-Version.');
133 switch (this.webSocketVersion) {
138 var e = new Error('Unsupported websocket client version: ' + this.webSocketVersion +
139 'Only versions 8 and 13 are supported.');
142 'Sec-WebSocket-Version': '13'
147 if (this.webSocketVersion === 13) {
148 this.origin = request.headers['origin'];
150 else if (this.webSocketVersion === 8) {
151 this.origin = request.headers['sec-websocket-origin'];
154 // Protocol is optional.
155 var protocolString = request.headers['sec-websocket-protocol'];
156 this.protocolFullCaseMap = {};
157 this.requestedProtocols = [];
158 if (protocolString) {
159 var requestedProtocolsFullCase = protocolString.split(headerValueSplitRegExp);
160 requestedProtocolsFullCase.forEach(function(protocol) {
161 var lcProtocol = protocol.toLocaleLowerCase();
162 self.requestedProtocols.push(lcProtocol);
163 self.protocolFullCaseMap[lcProtocol] = protocol;
167 if (!this.serverConfig.ignoreXForwardedFor &&
168 request.headers['x-forwarded-for']) {
169 var immediatePeerIP = this.remoteAddress;
170 this.remoteAddresses = request.headers['x-forwarded-for']
171 .split(xForwardedForSeparatorRegExp);
172 this.remoteAddresses.push(immediatePeerIP);
173 this.remoteAddress = this.remoteAddresses[0];
176 // Extensions are optional.
177 var extensionsString = request.headers['sec-websocket-extensions'];
178 this.requestedExtensions = this.parseExtensions(extensionsString);
180 // Cookies are optional
181 var cookieString = request.headers['cookie'];
182 this.cookies = this.parseCookies(cookieString);
185 WebSocketRequest.prototype.parseExtensions = function(extensionsString) {
186 if (!extensionsString || extensionsString.length === 0) {
189 var extensions = extensionsString.toLocaleLowerCase().split(headerValueSplitRegExp);
190 extensions.forEach(function(extension, index, array) {
191 var params = extension.split(headerParamSplitRegExp);
192 var extensionName = params[0];
193 var extensionParams = params.slice(1);
194 extensionParams.forEach(function(rawParam, index, array) {
195 var arr = rawParam.split('=');
200 array.splice(index, 1, obj);
204 params: extensionParams
206 array.splice(index, 1, obj);
211 // This function adapted from node-cookie
212 // https://github.com/shtylman/node-cookie
213 WebSocketRequest.prototype.parseCookies = function(str) {
215 if (!str || typeof(str) !== 'string') {
220 var pairs = str.split(cookieSeparatorRegEx);
222 pairs.forEach(function(pair) {
223 var eq_idx = pair.indexOf('=');
232 var key = pair.substr(0, eq_idx).trim();
233 var val = pair.substr(++eq_idx, pair.length).trim();
236 if ('"' === val[0]) {
237 val = val.slice(1, -1);
242 value: decodeURIComponent(val)
249 WebSocketRequest.prototype.accept = function(acceptedProtocol, allowedOrigin, cookies) {
250 this._verifyResolution();
252 // TODO: Handle extensions
254 var protocolFullCase;
256 if (acceptedProtocol) {
257 protocolFullCase = this.protocolFullCaseMap[acceptedProtocol.toLocaleLowerCase()];
258 if (typeof(protocolFullCase) === 'undefined') {
259 protocolFullCase = acceptedProtocol;
263 protocolFullCase = acceptedProtocol;
265 this.protocolFullCaseMap = null;
267 // Create key validation hash
268 var sha1 = crypto.createHash('sha1');
269 sha1.update(this.key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11');
270 var acceptKey = sha1.digest('base64');
272 var response = 'HTTP/1.1 101 Switching Protocols\r\n' +
273 'Upgrade: websocket\r\n' +
274 'Connection: Upgrade\r\n' +
275 'Sec-WebSocket-Accept: ' + acceptKey + '\r\n';
277 if (protocolFullCase) {
279 for (var i=0; i < protocolFullCase.length; i++) {
280 var charCode = protocolFullCase.charCodeAt(i);
281 var character = protocolFullCase.charAt(i);
282 if (charCode < 0x21 || charCode > 0x7E || separators.indexOf(character) !== -1) {
284 throw new Error('Illegal character "' + String.fromCharCode(character) + '" in subprotocol.');
287 if (this.requestedProtocols.indexOf(acceptedProtocol) === -1) {
289 throw new Error('Specified protocol was not requested by the client.');
292 protocolFullCase = protocolFullCase.replace(headerSanitizeRegExp, '');
293 response += 'Sec-WebSocket-Protocol: ' + protocolFullCase + '\r\n';
295 this.requestedProtocols = null;
298 allowedOrigin = allowedOrigin.replace(headerSanitizeRegExp, '');
299 if (this.webSocketVersion === 13) {
300 response += 'Origin: ' + allowedOrigin + '\r\n';
302 else if (this.webSocketVersion === 8) {
303 response += 'Sec-WebSocket-Origin: ' + allowedOrigin + '\r\n';
308 if (!Array.isArray(cookies)) {
310 throw new Error('Value supplied for "cookies" argument must be an array.');
312 var seenCookies = {};
313 cookies.forEach(function(cookie) {
314 if (!cookie.name || !cookie.value) {
316 throw new Error('Each cookie to set must at least provide a "name" and "value"');
319 // Make sure there are no \r\n sequences inserted
320 cookie.name = cookie.name.replace(controlCharsAndSemicolonRegEx, '');
321 cookie.value = cookie.value.replace(controlCharsAndSemicolonRegEx, '');
323 if (seenCookies[cookie.name]) {
325 throw new Error('You may not specify the same cookie name twice.');
327 seenCookies[cookie.name] = true;
329 // token (RFC 2616, Section 2.2)
330 var invalidChar = cookie.name.match(cookieNameValidateRegEx);
333 throw new Error('Illegal character ' + invalidChar[0] + ' in cookie name');
336 // RFC 6265, Section 4.1.1
337 // *cookie-octet / ( DQUOTE *cookie-octet DQUOTE ) | %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E
338 if (cookie.value.match(cookieValueDQuoteValidateRegEx)) {
339 invalidChar = cookie.value.slice(1, -1).match(cookieValueValidateRegEx);
341 invalidChar = cookie.value.match(cookieValueValidateRegEx);
345 throw new Error('Illegal character ' + invalidChar[0] + ' in cookie value');
348 var cookieParts = [cookie.name + '=' + cookie.value];
350 // RFC 6265, Section 4.1.1
351 // 'Path=' path-value | <any CHAR except CTLs or ';'>
353 invalidChar = cookie.path.match(controlCharsAndSemicolonRegEx);
356 throw new Error('Illegal character ' + invalidChar[0] + ' in cookie path');
358 cookieParts.push('Path=' + cookie.path);
361 // RFC 6265, Section 4.1.2.3
362 // 'Domain=' subdomain
364 if (typeof(cookie.domain) !== 'string') {
366 throw new Error('Domain must be specified and must be a string.');
368 invalidChar = cookie.domain.match(controlCharsAndSemicolonRegEx);
371 throw new Error('Illegal character ' + invalidChar[0] + ' in cookie domain');
373 cookieParts.push('Domain=' + cookie.domain.toLowerCase());
376 // RFC 6265, Section 4.1.1
377 //'Expires=' sane-cookie-date | Force Date object requirement by using only epoch
378 if (cookie.expires) {
379 if (!(cookie.expires instanceof Date)){
381 throw new Error('Value supplied for cookie "expires" must be a vaild date object');
383 cookieParts.push('Expires=' + cookie.expires.toGMTString());
386 // RFC 6265, Section 4.1.1
387 //'Max-Age=' non-zero-digit *DIGIT
389 var maxage = cookie.maxage;
390 if (typeof(maxage) === 'string') {
391 maxage = parseInt(maxage, 10);
393 if (isNaN(maxage) || maxage <= 0 ) {
395 throw new Error('Value supplied for cookie "maxage" must be a non-zero number');
397 maxage = Math.round(maxage);
398 cookieParts.push('Max-Age=' + maxage.toString(10));
401 // RFC 6265, Section 4.1.1
404 if (typeof(cookie.secure) !== 'boolean') {
406 throw new Error('Value supplied for cookie "secure" must be of type boolean');
408 cookieParts.push('Secure');
411 // RFC 6265, Section 4.1.1
413 if (cookie.httponly) {
414 if (typeof(cookie.httponly) !== 'boolean') {
416 throw new Error('Value supplied for cookie "httponly" must be of type boolean');
418 cookieParts.push('HttpOnly');
421 response += ('Set-Cookie: ' + cookieParts.join(';') + '\r\n');
425 // TODO: handle negotiated extensions
426 // if (negotiatedExtensions) {
427 // response += 'Sec-WebSocket-Extensions: ' + negotiatedExtensions.join(', ') + '\r\n';
430 // Mark the request resolved now so that the user can't call accept or
431 // reject a second time.
432 this._resolved = true;
433 this.emit('requestResolved', this);
437 var connection = new WebSocketConnection(this.socket, [], acceptedProtocol, false, this.serverConfig);
438 connection.webSocketVersion = this.webSocketVersion;
439 connection.remoteAddress = this.remoteAddress;
440 connection.remoteAddresses = this.remoteAddresses;
444 if (this._socketIsClosing) {
445 // Handle case when the client hangs up before we get a chance to
446 // accept the connection and send our side of the opening handshake.
447 cleanupFailedConnection(connection);
450 this.socket.write(response, 'ascii', function(error) {
452 cleanupFailedConnection(connection);
456 self._removeSocketCloseListeners();
457 connection._addSocketEventListeners();
461 this.emit('requestAccepted', connection);
465 WebSocketRequest.prototype.reject = function(status, reason, extraHeaders) {
466 this._verifyResolution();
468 // Mark the request resolved now so that the user can't call accept or
469 // reject a second time.
470 this._resolved = true;
471 this.emit('requestResolved', this);
473 if (typeof(status) !== 'number') {
476 var response = 'HTTP/1.1 ' + status + ' ' + httpStatusDescriptions[status] + '\r\n' +
477 'Connection: close\r\n';
479 reason = reason.replace(headerSanitizeRegExp, '');
480 response += 'X-WebSocket-Reject-Reason: ' + reason + '\r\n';
484 for (var key in extraHeaders) {
485 var sanitizedValue = extraHeaders[key].toString().replace(headerSanitizeRegExp, '');
486 var sanitizedKey = key.replace(headerSanitizeRegExp, '');
487 response += (sanitizedKey + ': ' + sanitizedValue + '\r\n');
492 this.socket.end(response, 'ascii');
494 this.emit('requestRejected', this);
497 WebSocketRequest.prototype._handleSocketCloseBeforeAccept = function() {
498 this._socketIsClosing = true;
499 this._removeSocketCloseListeners();
502 WebSocketRequest.prototype._removeSocketCloseListeners = function() {
503 this.socket.removeListener('end', this._socketCloseHandler);
504 this.socket.removeListener('close', this._socketCloseHandler);
507 WebSocketRequest.prototype._verifyResolution = function() {
508 if (this._resolved) {
509 throw new Error('WebSocketRequest may only be accepted or rejected one time.');
513 function cleanupFailedConnection(connection) {
514 // Since we have to return a connection object even if the socket is
515 // already dead in order not to break the API, we schedule a 'close'
516 // event on the connection object to occur immediately.
517 process.nextTick(function() {
518 // WebSocketConnection.CLOSE_REASON_ABNORMAL = 1006
519 // Third param: Skip sending the close frame to a dead socket
520 connection.drop(1006, 'TCP connection lost before handshake completed.', true);
524 module.exports = WebSocketRequest;