123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524 |
- /************************************************************************
- * 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 | <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=' 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;
|