/************************************************************************ * Copyright 2010-2011 Worlize Inc. * * 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 deprecation = require('./Deprecation'); var crypto = require('crypto'); var util = require('util'); var url = require('url'); var EventEmitter = require('events').EventEmitter; var WebSocketConnection = require('./WebSocketConnection'); var Constants = require('./Constants'); var headerValueSplitRegExp = /,\s*/; var headerParamSplitRegExp = /;\s*/; var headerSanitizeRegExp = /[\r\n]/g; 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) { this.socket = socket; this.httpRequest = httpRequest; this.resource = httpRequest.url; this.remoteAddress = socket.remoteAddress; this.serverConfig = serverConfig; } 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); // Deprecated websocketVersion (proper casing...) Object.defineProperty(this, "websocketVersion", { get: function() { deprecation.warn('websocketVersion'); return this.webSocketVersion; } }); 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 (request.headers['x-forwarded-for']) { this.remoteAddress = request.headers['x-forwarded-for'].split(', ')[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 []; } 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) { // TODO: Handle extensions if (acceptedProtocol) { var protocolFullCase = this.protocolFullCaseMap[acceptedProtocol.toLocaleLowerCase()]; if (typeof(protocolFullCase) === 'undefined') { protocolFullCase = acceptedProtocol; } } else { protocolFullCase = acceptedProtocol; } this.protocolFullCaseMap = null; var connection = new WebSocketConnection(this.socket, [], acceptedProtocol, false, this.serverConfig); connection.webSocketVersion = this.webSocketVersion; // Deprecated websocketVersion (proper casing...) Object.defineProperty(connection, "websocketVersion", { get: function() { deprecation.warn('websocketVersion'); return this.webSocketVersion; } }); connection.remoteAddress = this.remoteAddress; // 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 | 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."); } var domain = cookie.domain.toLowerCase(); 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"; // } response += "\r\n"; try { this.socket.write(response, 'ascii'); } catch(e) { if (Constants.DEBUG) { console.log("Error Writing to Socket: " + e.toString()); } // 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); }); } this.emit('requestAccepted', connection); return connection; }; WebSocketRequest.prototype.reject = function(status, reason, extraHeaders) { 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); }; module.exports = WebSocketRequest;