39c8b14f |
1 | /************************************************************************ |
2 | * Copyright 2010-2011 Worlize Inc. |
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 | |
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'); |
22 | var Constants = require('./Constants'); |
23 | |
24 | var headerValueSplitRegExp = /,\s*/; |
25 | var headerParamSplitRegExp = /;\s*/; |
26 | var headerSanitizeRegExp = /[\r\n]/g; |
27 | var separators = [ |
28 | "(", ")", "<", ">", "@", |
29 | ",", ";", ":", "\\", "\"", |
30 | "/", "[", "]", "?", "=", |
31 | "{", "}", " ", String.fromCharCode(9) |
32 | ]; |
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)); |
37 | } |
38 | |
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; |
43 | |
44 | var cookieSeparatorRegEx = /; */; |
45 | var cookieCaptureRegEx = /(.*?)=(.*)/; |
46 | |
47 | var httpStatusDescriptions = { |
48 | 100: "Continue", |
49 | 101: "Switching Protocols", |
50 | 200: "OK", |
51 | 201: "Created", |
52 | 203: "Non-Authoritative Information", |
53 | 204: "No Content", |
54 | 205: "Reset Content", |
55 | 206: "Partial Content", |
56 | 300: "Multiple Choices", |
57 | 301: "Moved Permanently", |
58 | 302: "Found", |
59 | 303: "See Other", |
60 | 304: "Not Modified", |
61 | 305: "Use Proxy", |
62 | 307: "Temporary Redirect", |
63 | 400: "Bad Request", |
64 | 401: "Unauthorized", |
65 | 402: "Payment Required", |
66 | 403: "Forbidden", |
67 | 404: "Not Found", |
68 | 406: "Not Acceptable", |
69 | 407: "Proxy Authorization Required", |
70 | 408: "Request Timeout", |
71 | 409: "Conflict", |
72 | 410: "Gone", |
73 | 411: "Length Required", |
74 | 412: "Precondition Failed", |
75 | 413: "Request Entity Too Long", |
76 | 414: "Request-URI Too Long", |
77 | 415: "Unsupported Media Type", |
78 | 416: "Requested Range Not Satisfiable", |
79 | 417: "Expectation Failed", |
80 | 426: "Upgrade Required", |
81 | 500: "Internal Server Error", |
82 | 501: "Not Implemented", |
83 | 502: "Bad Gateway", |
84 | 503: "Service Unavailable", |
85 | 504: "Gateway Timeout", |
86 | 505: "HTTP Version Not Supported" |
87 | }; |
88 | |
89 | function WebSocketRequest(socket, httpRequest, serverConfig) { |
90 | this.socket = socket; |
91 | this.httpRequest = httpRequest; |
92 | this.resource = httpRequest.url; |
93 | this.remoteAddress = socket.remoteAddress; |
94 | this.serverConfig = serverConfig; |
95 | } |
96 | |
97 | util.inherits(WebSocketRequest, EventEmitter); |
98 | |
99 | WebSocketRequest.prototype.readHandshake = function() { |
100 | var request = this.httpRequest; |
101 | |
102 | // Decode URL |
103 | this.resourceURL = url.parse(this.resource, true); |
104 | |
105 | this.host = request.headers['host']; |
106 | if (!this.host) { |
107 | throw new Error("Client must provide a Host header."); |
108 | } |
109 | |
110 | this.key = request.headers['sec-websocket-key']; |
111 | if (!this.key) { |
112 | throw new Error("Client must provide a value for Sec-WebSocket-Key."); |
113 | } |
114 | |
115 | this.webSocketVersion = parseInt(request.headers['sec-websocket-version'], 10); |
116 | this.websocketVersion = this.webSocketVersion; // Deprecated websocketVersion (proper casing...) |
117 | |
118 | if (!this.webSocketVersion || isNaN(this.webSocketVersion)) { |
119 | throw new Error("Client must provide a value for Sec-WebSocket-Version."); |
120 | } |
121 | |
122 | switch (this.webSocketVersion) { |
123 | case 8: |
124 | case 13: |
125 | break; |
126 | default: |
127 | var e = new Error("Unsupported websocket client version: " + this.webSocketVersion + |
128 | "Only versions 8 and 13 are supported."); |
129 | e.httpCode = 426; |
130 | e.headers = { |
131 | "Sec-WebSocket-Version": "13" |
132 | }; |
133 | throw e; |
134 | } |
135 | |
136 | if (this.webSocketVersion === 13) { |
137 | this.origin = request.headers['origin']; |
138 | } |
139 | else if (this.webSocketVersion === 8) { |
140 | this.origin = request.headers['sec-websocket-origin']; |
141 | } |
142 | |
143 | // Protocol is optional. |
144 | var protocolString = request.headers['sec-websocket-protocol']; |
145 | if (protocolString) { |
146 | this.requestedProtocols = protocolString.toLocaleLowerCase().split(headerValueSplitRegExp); |
147 | } |
148 | else { |
149 | this.requestedProtocols = []; |
150 | } |
151 | |
152 | if (request.headers['x-forwarded-for']) { |
153 | this.remoteAddress = request.headers['x-forwarded-for'].split(', ')[0]; |
154 | } |
155 | |
156 | // Extensions are optional. |
157 | var extensionsString = request.headers['sec-websocket-extensions']; |
158 | this.requestedExtensions = this.parseExtensions(extensionsString); |
159 | |
160 | // Cookies are optional |
161 | var cookieString = request.headers['cookie']; |
162 | this.cookies = this.parseCookies(cookieString); |
163 | }; |
164 | |
165 | WebSocketRequest.prototype.parseExtensions = function(extensionsString) { |
166 | if (!extensionsString || extensionsString.length === 0) { |
167 | return []; |
168 | } |
169 | extensions = extensionsString.toLocaleLowerCase().split(headerValueSplitRegExp); |
170 | extensions.forEach(function(extension, index, array) { |
171 | var params = extension.split(headerParamSplitRegExp); |
172 | var extensionName = params[0]; |
173 | var extensionParams = params.slice(1); |
174 | extensionParams.forEach(function(rawParam, index, array) { |
175 | var arr = rawParam.split('='); |
176 | var obj = { |
177 | name: arr[0], |
178 | value: arr[1] |
179 | }; |
180 | array.splice(index, 1, obj); |
181 | }); |
182 | var obj = { |
183 | name: extensionName, |
184 | params: extensionParams |
185 | }; |
186 | array.splice(index, 1, obj); |
187 | }); |
188 | return extensions; |
189 | }; |
190 | |
191 | WebSocketRequest.prototype.parseCookies = function(cookieString) { |
192 | if (!cookieString || cookieString.length === 0) { |
193 | return []; |
194 | } |
195 | var cookies = []; |
196 | var cookieArray = cookieString.split(cookieSeparatorRegEx); |
197 | |
198 | cookieArray.forEach(function(cookie) { |
199 | if (cookie && cookie.length !== 0) { |
200 | var cookieParts = cookie.match(cookieCaptureRegEx); |
201 | cookies.push({ |
202 | name: cookieParts[1], |
203 | value: cookieParts[2] |
204 | }); |
205 | } |
206 | }); |
207 | return cookies; |
208 | }; |
209 | |
210 | WebSocketRequest.prototype.accept = function(acceptedProtocol, allowedOrigin, cookies) { |
211 | // TODO: Handle extensions |
212 | var connection = new WebSocketConnection(this.socket, [], acceptedProtocol, false, this.serverConfig); |
213 | |
214 | connection.webSocketVersion = this.webSocketVersion; |
215 | connection.websocketVersion = this.webSocketVersion; // deprecated.. proper casing |
216 | connection.remoteAddress = this.remoteAddress; |
217 | |
218 | // Create key validation hash |
219 | var sha1 = crypto.createHash('sha1'); |
220 | sha1.update(this.key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"); |
221 | var acceptKey = sha1.digest('base64'); |
222 | |
223 | var response = "HTTP/1.1 101 Switching Protocols\r\n" + |
224 | "Upgrade: websocket\r\n" + |
225 | "Connection: Upgrade\r\n" + |
226 | "Sec-WebSocket-Accept: " + acceptKey + "\r\n"; |
227 | |
228 | if (acceptedProtocol) { |
229 | // validate protocol |
230 | for (var i=0; i < acceptedProtocol.length; i++) { |
231 | var charCode = acceptedProtocol.charCodeAt(i); |
232 | var character = acceptedProtocol.charAt(i); |
233 | if (charCode < 0x21 || charCode > 0x7E || separators.indexOf(character) !== -1) { |
234 | this.reject(500); |
235 | throw new Error("Illegal character '" + String.fromCharCode(character) + "' in subprotocol."); |
236 | } |
237 | } |
238 | if (this.requestedProtocols.indexOf(acceptedProtocol) === -1) { |
239 | this.reject(500); |
240 | throw new Error("Specified protocol was not requested by the client."); |
241 | } |
242 | |
243 | acceptedProtocol = acceptedProtocol.replace(headerSanitizeRegExp, ''); |
244 | response += "Sec-WebSocket-Protocol: " + acceptedProtocol + "\r\n"; |
245 | } |
246 | if (allowedOrigin) { |
247 | allowedOrigin = allowedOrigin.replace(headerSanitizeRegExp, ''); |
248 | if (this.webSocketVersion === 13) { |
249 | response += "Origin: " + allowedOrigin + "\r\n"; |
250 | } |
251 | else if (this.webSocketVersion === 8) { |
252 | response += "Sec-WebSocket-Origin: " + allowedOrigin + "\r\n"; |
253 | } |
254 | } |
255 | |
256 | if (cookies) { |
257 | if (!Array.isArray(cookies)) { |
258 | this.reject(500); |
259 | throw new Error("Value supplied for 'cookies' argument must be an array."); |
260 | } |
261 | var seenCookies = {}; |
262 | cookies.forEach(function(cookie) { |
263 | if (!cookie.name || !cookie.value) { |
264 | this.reject(500); |
265 | throw new Error("Each cookie to set must at least provide a 'name' and 'value'"); |
266 | } |
267 | |
268 | // Make sure there are no \r\n sequences inserted |
269 | cookie.name = cookie.name.replace(controlCharsAndSemicolonRegEx, ''); |
270 | cookie.value = cookie.value.replace(controlCharsAndSemicolonRegEx, ''); |
271 | |
272 | if (seenCookies[cookie.name]) { |
273 | this.reject(500); |
274 | throw new Error("You may not specify the same cookie name twice."); |
275 | } |
276 | seenCookies[cookie.name] = true; |
277 | |
278 | // token (RFC 2616, Section 2.2) |
279 | var invalidChar = cookie.name.match(cookieNameValidateRegEx); |
280 | if (invalidChar) { |
281 | this.reject(500); |
282 | throw new Error("Illegal character " + invalidChar[0] + " in cookie name"); |
283 | } |
284 | |
285 | // RFC 6265, Section 4.1.1 |
286 | // *cookie-octet / ( DQUOTE *cookie-octet DQUOTE ) | %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E |
287 | if (cookie.value.match(cookieValueDQuoteValidateRegEx)) { |
288 | invalidChar = cookie.value.slice(1, -1).match(cookieValueValidateRegEx); |
289 | } else { |
290 | invalidChar = cookie.value.match(cookieValueValidateRegEx); |
291 | } |
292 | if (invalidChar) { |
293 | this.reject(500); |
294 | throw new Error("Illegal character " + invalidChar[0] + " in cookie value"); |
295 | } |
296 | |
297 | var cookieParts = [cookie.name + "=" + cookie.value]; |
298 | |
299 | // RFC 6265, Section 4.1.1 |
300 | // "Path=" path-value | <any CHAR except CTLs or ";"> |
301 | if(cookie.path){ |
302 | invalidChar = cookie.path.match(controlCharsAndSemicolonRegEx); |
303 | if (invalidChar) { |
304 | this.reject(500); |
305 | throw new Error("Illegal character " + invalidChar[0] + " in cookie path"); |
306 | } |
307 | cookieParts.push("Path=" + cookie.path); |
308 | } |
309 | |
310 | // RFC 6265, Section 4.1.2.3 |
311 | // "Domain=" subdomain |
312 | if (cookie.domain) { |
313 | if (typeof(cookie.domain) !== 'string') { |
314 | this.reject(500); |
315 | throw new Error("Domain must be specified and must be a string."); |
316 | } |
317 | var domain = cookie.domain.toLowerCase(); |
318 | invalidChar = cookie.domain.match(controlCharsAndSemicolonRegEx); |
319 | if (invalidChar) { |
320 | this.reject(500); |
321 | throw new Error("Illegal character " + invalidChar[0] + " in cookie domain"); |
322 | } |
323 | cookieParts.push("Domain=" + cookie.domain.toLowerCase()); |
324 | } |
325 | |
326 | // RFC 6265, Section 4.1.1 |
327 | //"Expires=" sane-cookie-date | Force Date object requirement by using only epoch |
328 | if (cookie.expires) { |
329 | if (!(cookie.expires instanceof Date)){ |
330 | this.reject(500); |
331 | throw new Error("Value supplied for cookie 'expires' must be a vaild date object"); |
332 | } |
333 | cookieParts.push("Expires=" + cookie.expires.toGMTString()); |
334 | } |
335 | |
336 | // RFC 6265, Section 4.1.1 |
337 | //"Max-Age=" non-zero-digit *DIGIT |
338 | if (cookie.maxage) { |
339 | var maxage = cookie.maxage; |
340 | if (typeof(maxage) === 'string') { |
341 | maxage = parseInt(maxage, 10); |
342 | } |
343 | if (isNaN(maxage) || maxage <= 0 ) { |
344 | this.reject(500); |
345 | throw new Error("Value supplied for cookie 'maxage' must be a non-zero number"); |
346 | } |
347 | maxage = Math.round(maxage); |
348 | cookieParts.push("Max-Age=" + maxage.toString(10)); |
349 | } |
350 | |
351 | // RFC 6265, Section 4.1.1 |
352 | //"Secure;" |
353 | if (cookie.secure) { |
354 | if (typeof(cookie.secure) !== "boolean") { |
355 | this.reject(500); |
356 | throw new Error("Value supplied for cookie 'secure' must be of type boolean"); |
357 | } |
358 | cookieParts.push("Secure"); |
359 | } |
360 | |
361 | // RFC 6265, Section 4.1.1 |
362 | //"HttpOnly;" |
363 | if (cookie.httponly) { |
364 | if (typeof(cookie.httponly) !== "boolean") { |
365 | this.reject(500); |
366 | throw new Error("Value supplied for cookie 'httponly' must be of type boolean"); |
367 | } |
368 | cookieParts.push("HttpOnly"); |
369 | } |
370 | |
371 | response += ("Set-Cookie: " + cookieParts.join(';') + "\r\n"); |
372 | }.bind(this)); |
373 | } |
374 | |
375 | // TODO: handle negotiated extensions |
376 | // if (negotiatedExtensions) { |
377 | // response += "Sec-WebSocket-Extensions: " + negotiatedExtensions.join(", ") + "\r\n"; |
378 | // } |
379 | |
380 | response += "\r\n"; |
381 | try { |
382 | this.socket.write(response, 'ascii'); |
383 | } |
384 | catch(e) { |
385 | if (Constants.DEBUG) { |
386 | console.log("Error Writing to Socket: " + e.toString()); |
387 | } |
388 | // Since we have to return a connection object even if the socket is |
389 | // already dead in order not to break the API, we schedule a 'close' |
390 | // event on the connection object to occur immediately. |
391 | process.nextTick(function() { |
392 | // WebSocketConnection.CLOSE_REASON_ABNORMAL = 1006 |
393 | // Third param: Skip sending the close frame to a dead socket |
394 | connection.drop(1006, "TCP connection lost before handshake completed.", true); |
395 | }); |
396 | } |
397 | |
398 | this.emit('requestAccepted', connection); |
399 | |
400 | return connection; |
401 | }; |
402 | |
403 | WebSocketRequest.prototype.reject = function(status, reason, extraHeaders) { |
404 | if (typeof(status) !== 'number') { |
405 | status = 403; |
406 | } |
407 | var response = "HTTP/1.1 " + status + " " + httpStatusDescriptions[status] + "\r\n" + |
408 | "Connection: close\r\n"; |
409 | if (reason) { |
410 | reason = reason.replace(headerSanitizeRegExp, ''); |
411 | response += "X-WebSocket-Reject-Reason: " + reason + "\r\n"; |
412 | } |
413 | |
414 | if (extraHeaders) { |
415 | for (var key in extraHeaders) { |
416 | var sanitizedValue = extraHeaders[key].toString().replace(headerSanitizeRegExp, ''); |
417 | var sanitizedKey = key.replace(headerSanitizeRegExp, ''); |
418 | response += (sanitizedKey + ": " + sanitizedValue + "\r\n"); |
419 | } |
420 | } |
421 | |
422 | response += "\r\n"; |
423 | this.socket.end(response, 'ascii'); |
424 | |
425 | this.emit('requestRejected', this); |
426 | }; |
427 | |
428 | module.exports = WebSocketRequest; |