525 lines
18 KiB
JavaScript
525 lines
18 KiB
JavaScript
/************************************************************************
|
|
* 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;
|