xref: /unit/src/nodejs/unit-http/websocket_request.js (revision 2617:18a10bb7346d)
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